jev-bridge 0.2.0 — written 20 September 2026, updated 21 September, against Jev 1.13.0.

Claude Code asks a question. Jev answers with a probability.

The TypeSafe plugin could only ever tell me how to write a good question. It had no way to send one. This is the piece that sends it — and, now, remembers the answer and keeps the books.

state: "Help! My payouts have been failing for 3 days."

Which team should handle this?

billing0.87
technical0.13
sales0.00

confidence 0.80399 input tokenscost $0.0000168

184 msa live answer
0.077 msthe same answer again
2,370×faster on a repeat
170tests, all green
0dependencies

What is in the plugin: six files, none of them code

A Claude Code “skill” is not a program. It is text loaded into my context. Counting the files is the quickest way to see why a bridge had to exist.

The plugin, as installed

~/.claude/plugins/cache/typesafe-ai/typesafe/0.5.7/ ├── .claude-plugin/ │ ├── marketplace.json 382 B names the plugin for the marketplace │ └── plugin.json 320 B name, version, author, licence ├── skills/ │ └── typesafe-ai/ │ ├── LICENSE 1068 B MIT, identical to the one above │ └── SKILL.md 10040 B the entire product: guidance for the agent ├── LICENSE 1068 B MIT └── README.md 1336 B install instructions 6 files, 4 directories, 14,214 bytes. No .js, .mjs, .py, .sh or .ts anywhere — zero bytes of anything executable. SKILL.md alone is 71% of it.

Two details worth pointing at. The two LICENSE files are byte-for-byte identical — git stores them as one object, and they account for 15% of the plugin between them. And SKILL.md is not configuration: it is prose that gets pasted into my context, which is why it can tell me how to phrase a score rubric but can never open a socket.

A seventh, eighth and ninth file appear on disk — .in_use/51370 and friends. Those are lock markers Claude Code writes, one per process holding the plugin, not part of what TypeSafe ships. The upstream git tree at tag v0.5.7 contains exactly the six above, and the marketplace clone beside it is the same six plus a 136 KB .git.

What the plugin can do

  • tell me the three question types
  • tell me how to write criteria
  • point me at the live docs

What a live call needs

  • something holding an API key
  • something that can open a socket
  • something Claude Code can invoke

None of these fit in a Markdown file.

TypeSafe themselves publish no MCP server — not in that repo, not among their ten GitHub repositories, and the word never appears in their documentation index. Their two SDKs are libraries with no executable. jev-bridge is therefore independent code; only the API contract it speaks is theirs. (A separate third-party server, jev-mcp, appeared on npm in September 2026 — built on the official SDKs, with different trade-offs.)

The bridge, which is the part that runs

jev-bridge/ the repository ├── src/ │ ├── server.mjs 617 lines the 5 tools, question checks, the CLI │ ├── mcp.mjs 260 lines JSON-RPC over stdio, both protocol eras │ ├── catalog.mjs 538 lines instructions, tool schemas, the guide, prompts │ ├── typesafe.mjs 226 lines the key, retries, timeouts, errors │ ├── store.mjs 631 lines SQLite cache, usage log, history; memory fallback │ ├── history.mjs 197 lines certainty, filters, the stats a review reads │ ├── ui.mjs 128 lines the dashboard's local server │ └── ui.html 894 lines the dashboard page, nothing fetched from the web ├── test/ 1,995 lines 170 tests, run by node --test ├── examples/ 132 lines build an evaluation set, replay it ├── evidence/ recorded Claude Code sessions; the install guide and examples, run ├── docs/ architecture, analytics, recipes, and this page ├── LICENSE MIT └── package.json no dependencies 4,724 lines of JavaScript and one page, 0 dependencies.
~/.jev-bridge/ created 0700 on first use ├── .env chmod 600 your API key, and nothing else └── jev.db cached answers, the usage log, the call history While the database is open, SQLite adds jev.db-wal and jev.db-shm beside it; a checkpoint folds them back in.

No, we are not hosting anything

The word “server” misleads here. It suggests a machine, a port, a bill. There is none of that.

It is a subprocess, not a service.

Claude Code starts node server.mjs as a child process and talks to it down an ordinary pipe. It listens on no port, accepts no inbound connection, and exits with your session. The only hosted thing is Jev, on TypeSafe's infrastructure, reached over plain HTTPS.

flowchart TB
  subgraph MAC["Your Mac"]
    direction TB
    CC["Claude Code
(CLI process)"] SRV["server.mjs
child process · stdio only"] KEY[("TYPESAFE_API_KEY
.env · chmod 600")] DB[("jev.db
answers + usage")] CC <== "JSON-RPC 2.0
over stdin / stdout" ==> SRV KEY -. "read per call" .-> SRV SRV <== "hit: 0.08 ms" ==> DB end subgraph ANT["Anthropic"] MODEL["Claude
decides when to ask"] end subgraph TS["TypeSafe"] API["api.typesafe.ai/v1"] JEV["Jev 1.13.0"] API --> JEV end CC <-- "conversation +
tool results" --> MODEL SRV -- "on a miss only:
HTTPS + Bearer" --> API classDef zone fill:#F8FAFC,stroke:#C9D2DD,color:#16202E classDef box fill:#FFFFFF,stroke:#2540D9,color:#16202E classDef secret fill:#FBEAE2,stroke:#9A3412,color:#9A3412 class MAC,ANT,TS zone class CC,SRV,MODEL,API,JEV,DB box class KEY secret
Three trust zones. The key is read inside your machine and attached to a request going straight to TypeSafe — it never travels to Anthropic, and is never written into any config file. Since the answer store sits beside the bridge, a repeated question never leaves the Mac at all.

This is also why one registration serves every project: there is no per-project deployment. One file on disk, one line in the user-scope config, available everywhere.

What happens when I ask

MCP is a small protocol: line-delimited JSON-RPC over a pipe. The handshake runs once; everything after is a tool call.

sequenceDiagram
  autonumber
  participant CC as Claude Code
  participant S as server.mjs
  participant DB as jev.db
  participant T as api.typesafe.ai

  CC->>S: tools/call jev_ask {state, questions}
  S->>S: validate every question
  Note right of S: a malformed question stops here,
naming its own field, at no cost S->>S: fingerprint the questions — ids excluded S->>DB: look up state + questions + model alt stored, still fresh, same model DB-->>S: the answers S-->>CC: answers · cached:true · 0.08 ms else nothing usable stored S->>T: POST /v1/systemone + Bearer alt 408, 429 or 5xx T-->>S: timed out / rate limited / overloaded S->>S: sleep(Retry-After), back off, retry (at most twice) end T-->>S: 200 — answers + usage S->>DB: store the answers S-->>CC: answers · cached:false · ~180 ms end S-)DB: once idle — log tokens and cost, record the call for review
Errors are never cached. A 529 today must not become a week of cached failure, so only a 200 is stored as an answer — a rule with a test of its own. Every call, failures included, is logged, but only after the answer has gone back.

The three question types

What to reach for, and what comes back
TypeAsk it whenYou get back
noulA condition either holds or it doesn'tOne probability, 0 → 1. No separate confidence. 0.5 means genuinely torn, not “medium”.
choiceExactly one option from a set you defineThe winner, the full distribution, and a confidence.
scoreA degree along a rubric you describeA weighted position that can land between your levels, plus legend and distribution.

Six things worth asking Jev

Every number below came back from the live API while this page was being written. None of it is illustrative.

1. Route a request and fill its arguments in the same trip

Pick the handler and the arguments each branch would need, all at once. The questions cannot see each other, so you state the branch as a premise — “IF this is a refund…” — and your code reads only the answers that apply.

"Can you refund my last order? The mug arrived cracked and I have photos."

handlerchoiceissue_refund
refund_reasonchoicedamaged
has_evidencenoul0.99
needs_humannoul0.20

Both choices came back at confidence 1.00, every rival option at zero. One round trip, 527 input tokens, 233 ms — a router and its arguments for $0.000022.

2. Rerank what your search returned

Retrieval gets you candidates; it does not know which one answers the question. Give each passage its own comparable score against the query, in a single call, and sort in code.

query: "How do I rotate the API key without downtime?"

bscore 0–32.99
cscore 0–31.06
dscore 0–30.10
ascore 0–30.06

Passage b (“add the new key first, deploy, then revoke the old one”) lands at the top level. The instructive one is c at 1.06: it is about API keys but never answers the question, and it scores exactly one level up from the unrelated pair. A keyword search would have ranked it first.

3. Check a claim against its evidence

The guard to put in front of anything a language model asserts. Ask support and contradiction separately — “unsupported” and “contradicted” are different failures and deserve different handling.

claim: "The free plan includes 10 GB of storage." source: "Free accounts may store up to 2 GB. Pro accounts include 100 GB…"

supportednoul0.01
contradictednoul0.95

A plausible-looking number, caught. Note it is not merely unsupported — 0.95 says the source says otherwise, which is the case to escalate rather than quietly drop.

4. Apply labels that can all be true at once

The most common modelling mistake is reaching for choice here. A choice forces one winner and would have to discard three true facts. One noul per label is the answer.

"Third time this week the export button does nothing. I am on the Pro plan paying $40/mo and I want a refund if this is not fixed today."

reports_bugnoul0.97
mentions_billingnoul0.99
requests_refundnoul0.97
churn_risknoul0.88

All four are true, and the message is one sentence. churn_risk sits lower at 0.88 because it is inferred rather than stated — which is the honest answer, and a useful one to threshold on.

5. Let code find the candidates, and Jev pick the right one

Do not ask a model to extract a date — ask it to choose one. A regex finds every date reliably; only the choice needs judgment. The value you copy is then guaranteed to be one that really occurs in the source.

"Ordered 3 Jan, dispatched 5 Jan, and it should reach you by 11 Jan. Returns close 25 Jan." candidates: [3 Jan, 5 Jan, 11 Jan, 25 Jan]

delivery_datechoice11 Jan

Confidence 1.00, with the three decoy dates at zero. Include a none option, as this did: without one, a set of candidates that misses the real answer forces a confident wrong pick.

6. Score the dimensions once, decide the policy in code

Keep the judgment and the policy apart. Jev rates each dimension; your code weights them. Changing a weight or a threshold then costs nothing, because the evidence has not changed and nothing needs re-asking.

A pull request description, in full: "Fixes the thing. See ticket."

clarityscore 0–30.05
testabilityscore 0–20.20

In code: 0.6 × clarity/3 + 0.4 × testability/2 = 0.05. Worth noticing that testability came back at confidence 0.69, the lowest of anything on this page — there is genuinely little to judge in six words, and the model says so rather than guessing firmly.

All six ran in 1.3 seconds and cost $0.000162 together. Every one is now cached, so re-running the page's examples costs nothing.

The shape of a call, and what it costs

One state, three questions, one round trip

Independent questions ride together — Jev reads the state once and answers all of them in parallel.

// tools/call → jev_ask
{
  "state": "Help! My payouts have been failing for 3 days.",
  "questions": {
    "is_urgent":   { "type": "noul",   "instructions": "Does this convey urgency?" },
    "department":  { "type": "choice", "instructions": "Which team should handle this?",
                      "criteria": { "billing":   "Payments, invoicing, refunds",
                                    "technical": "Bugs, outages, integrations",
                                    "sales":     "Pricing, upgrades" } },
    "frustration": { "type": "score",  "instructions": "How frustrated is the customer?",
                      "criteria": ["Calm", "Frustrated", "Very angry"] }
  }
}
// ← 200 from jev-1.13.0
{ "is_urgent":   { "noul": 0.95 },
  "department":  { "choice": "billing", "confidence": 0.80,
                   "probabilities": { "billing": 0.87, "technical": 0.13, "sales": 0.0 } },
  "frustration": { "score": 1.05, "confidence": 0.92,
                   "legend": { "0": "Calm", "1": "Frustrated", "2": "Very angry" },
                   "probabilities": { "0": 0.0, "1": 0.95, "2": 0.05 } } }
// usage: 399 input / 73 output · bridge: cached false, 184 ms, $0.0000168

The frustration score of 1.05 is the useful illustration: not level 1, but just past it, because 5% of the mass sits on “Very angry”. A plain label throws that away.

Is Jev deterministic? Nearly

Worth checking, since a cache freezes one answer. Three identical live calls:

runis_urgentbillingfrustrationlatency
10.950.891.05235 ms
20.950.851.04195 ms
30.950.861.04121 ms

The decisions never move — billing every time, the same noul. The probabilities wobble by about 0.02 in the second decimal. So caching is safe, and it even makes repeated runs more reproducible than the API is. The one caution: if you tune a threshold right at a boundary, a cached answer pins one sample of that wobble. Pass cache: false when you are measuring rather than deciding.

What it costs

Input is billed at $42 per billion tokens and output is free, so the 399-token call above cost about $0.0000168 — roughly 60,000 calls per dollar. Jev's ceilings are 64k tokens per request (32k for state plus the longest single question), 1,200 requests per minute, and 250,000 tokens per second.

Why SQLite, and not a JSON file

Two things had to be saved: answers, so a repeat costs nothing, and usage, so spend is a number you can look at.

The deciding constraint is not speed. It is that every Claude Code session starts its own bridge process, and they all share one store. That rules out most of the simple options before performance is even discussed.

Judged against the way this store is actually used
OptionFind one answerTwo sessions writing at onceEvicting old entries
JSON fileload the whole fileloses whichever write lands secondrewrite everything
CSVscan every rowappends survive, edits do notrewrite everything
JSONLload it all into memoryappends surviveneeds compaction, which races
XMLparse the whole documentsame failure as JSONrewrite everything
SQLiteindexed lookupbuilt for it (WAL)one statement

And it costs no dependency: node:sqlite is built into Node from 22.5 onward. The bridge still installs nothing.

flowchart LR
  A["session A
server.mjs"] --> DB[("jev.db
WAL + busy_timeout")] B["session B
server.mjs"] --> DB C["session C
server.mjs"] --> DB DB --> R["800 writes from 2 processes,
nothing lost"] classDef n fill:#FFFFFF,stroke:#2540D9,color:#16202E classDef s fill:#F8FAFC,stroke:#5A6B80,color:#16202E classDef g fill:#E5E9FB,stroke:#2540D9,color:#1B2C9E class A,B,C n class DB s class R g
Asserted by a test, not by argument: two real processes each write 400 entries to one file and every one survives. Delete the busy_timeout line and that test fails — which is exactly what a JSON file would do, quietly, with no line to delete.

The five tables

jev.db ├── cache key (sha256) · requested_model · resolved_model · responsecreated_at · last_hit_at · hits — WITHOUT ROWID, indexed by use ├── calls ts · cached · questions · input_tokens · output_tokenssaved_input_tokens · saved_usd · latency_ms · cost_usd · status ├── aliases requested → resolved — how a moved model alias is noticed ├── history one row per call: timing, retries, cost, certainty, answers, review └── payloads each state and question set, stored once however often it is sent The cache stores answers only, filed under hashes. The history is the one place your state and questions are kept — so a reviewer can judge the answer — and only while TYPESAFE_HISTORY is full. On meta it keeps timings and answers, never the text.
184 mslive, average of 3
0.077 mscached, median of 20
$0cost of a hit
45 KBthe whole database

The bug the cache had, found by using it

The unit tests passed. Driving it from a real Claude Code session showed the cache almost never hitting.

Two sessions asked the identical thing. The only difference:

// session A
{ "urgency":       { "type": "noul", "instructions": "Does this convey urgency?" } }
// session B
{ "urgency_check": { "type": "noul", "instructions": "Does this convey urgency?" } }

Same state, same question, same type — a different id, invented by the model. TypeSafe's own contract says question ids are never sent to the model, so to Jev these are one question. The first cache keyed on them anyway, and since an LLM picks a fresh id each run, it would have missed nearly every time it mattered.

Now a question is fingerprinted by its meaning, ids are excluded from the key, and answers are handed back under whatever ids the caller used. Against the live API:

call 1  id=alpha         cached=false  {"alpha": {"noul": 0.97}}   166.02 ms
call 2  id=beta          cached=true   {"beta":  {"noul": 0.97}}     0.15 ms
call 3  reworded text    cached=false  — a different question must miss

A caveat worth knowing: an agent rephrases. Two independent sessions asked the same thing in words that differed by 46 tokens, and correctly missed. Expect hits within a session and from code that sends a fixed question — not across sessions where the model writes the question fresh each time.

Where the key lives

A key belongs out of every file that gets shared. Here is how jev-bridge keeps it there.

The cache and the usage log hold answers, token counts and timings — never the content you asked about. The call history does keep it, by default, because judging whether an answer was right needs the question; set TYPESAFE_HISTORY=meta to keep only timings and answers, or off for nothing. None of it leaves your machine. See SECURITY.md.

Looking back at calls

Every call is kept for review, so you can ask two questions of it later: was it efficient, and was it right?

Each jev_ask — answered live, from the cache, rejected or timed out — is recorded with what was asked, what came back, how long it took, how many tries, and what it cost. jev-bridge --ui opens a dashboard on 127.0.0.1: latency percentiles, the cache hit rate, spend, a dot per live call, and the list of calls. Open one to see the state, every question, and each answer drawn as bars; mark it correct, partly right or wrong, and click the option that should have won.

What the history can tell you
QuestionWhere the answer comes from
Was it fast?Latency per call; p50 and p95 over live calls
Did it wait on rate limits?How many tries each call took
Was it batched?Re-sent states: live calls that sent a state already sent — their questions could have shared one call
Was Jev sure?Certainty, from the least sure answer: a noul by its distance from 0.5, a choice or score by Jev's confidence
Was it right?Your reviews — and, from them, accuracy

Claude reaches the same history through jev_history and jev_review, so it can record a verdict itself when you correct an answer. Reviewed calls are never pruned: they become an evaluation set that examples/replay.mjs scores any model against, before you switch to it. docs/analytics.md shows how to query all of it directly.

It costs the answer nothing. Records queue in memory and are written in one transaction once the bridge has been idle for 20 ms. Measured end to end over MCP, median and p95 latency are unchanged against the version without history; the one cost is 2–4 ms at p99 in an unbroken burst of back-to-back calls.

How it is tested

170 tests, no test framework installed — node --test is built in, like the database.

Only the TypeSafe API is faked, because it is external and billed. SQLite, the MCP protocol and process contention all run for real. Every behaviour is checked twice: once against the SQLite store and once against the memory fallback.

Passing tests prove nothing until they have been seen to fail, so each was re-run against a deliberately broken copy of the code:

Break the code this way, and a test must notice
MutationCaught
Cache key stops ignoring key orderyes
Cache key includes the caller's question idsyes
Score levels get sorted, losing their orderyes
Answers returned without remapping to the caller's idsyes
TTL never expires an entryyes
Failed calls get cachedyes
A moved model alias goes undetectedyes
Eviction by age instead of by useyes
busy_timeout removedyes
Question validation skippedyes
Question text stored in the cache instead of a hashyes
History written on the answer's path, or on the next tick instead of when idleyes
meta keeping the stateyes
Reviewed calls pruned by ageyes
Certainty taken from the most sure answeryes
The dashboard skipping its Host check, or its tokenyes
A replay ignoring the reviewer's expected answeryes
A key with a line break in it reaching the requestyes
A neighbouring variable in the key file taken for the keyyes
The key sent over plain HTTP, or a redirect followedyes
Database files created readable by everyoneyes
A cleared history left readable in the file's free pagesyes
The dashboard's Content-Security-Policy on the page aloneyes

Beyond that, the bridge was driven from a real Claude Code session end to end — which is how the question-id bug was found, and something no unit test had thought to ask.

Built, declined, left

Done

Declined

Swapping in the official SDK would inherit maintained retries and types, but costs the zero-dependency property — which is what lets a bare node server.mjs start with no install step and nothing to rot. A jev_decide tool that bakes confidence thresholds into the bridge stays unbuilt for a stronger reason: TypeSafe's own guidance is to keep raw judgments reusable and policy in your code. Bake a threshold in and every caller inherits a decision they cannot see — exactly what case 6 above keeps separate.

Left

Working with it

# is the credential still good? bypasses the cache and calls the API
node src/server.mjs --selftest

# what has it cost, over the last 7 days?
node src/server.mjs --stats 7

# look back at calls: a dashboard, or JSON
node src/server.mjs --ui
node src/server.mjs --history 7 uncertain

# is jev-preview better on the calls you reviewed?
node examples/eval-set.mjs > eval.jsonl
node examples/replay.mjs eval.jsonl jev-preview

# throw away stored answers; keep the usage log and the history
node src/server.mjs --clear-cache

# the tests
npm test

# rotate the key — one file, every project follows
printf 'TYPESAFE_API_KEY=%s\n' "$NEW" > ~/.jev-bridge/.env
chmod 600 ~/.jev-bridge/.env

Five tools reach Claude Code: jev_ask, jev_usage, jev_history, jev_review and jev_models; so do four resources (jev://guide among them, for @-mentions) and three prompts, offered as /mcp__jev__review_uncertain, /mcp__jev__cost_report and /mcp__jev__question_design. The bridge speaks MCP 2026-07-28 and the older initialize handshake alike. A session already running when the bridge changed fixed its tool list at startup and will not see them — start a new one.

Knobs, all optional: JEV_BRIDGE_HOME (~/.jev-bridge), TYPESAFE_CACHE_TTL_DAYS (7), TYPESAFE_CACHE_MAX (20000), TYPESAFE_DB, TYPESAFE_DEFAULT_MODEL (jev-latest), TYPESAFE_USD_PER_MTOK (0.042), TYPESAFE_BASE_URL, TYPESAFE_TIMEOUT_MS (10000 per attempt), TYPESAFE_MAX_RETRIES (2), TYPESAFE_HISTORY (full, meta or off), TYPESAFE_HISTORY_DAYS (30), TYPESAFE_HISTORY_MAX (10000).