jev-bridge 0.2.0 — written 20 September 2026, updated 21 September, against Jev 1.13.0.
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?
confidence 0.80399 input tokenscost $0.0000168
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.
.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.
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.)
jev.db-wal and jev.db-shm beside it;
a checkpoint folds them back in.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
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.
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
| Type | Ask it when | You get back |
|---|---|---|
noul | A condition either holds or it doesn't | One probability, 0 → 1. No separate confidence. 0.5 means genuinely torn, not “medium”. |
choice | Exactly one option from a set you define | The winner, the full distribution, and a confidence. |
score | A degree along a rubric you describe | A weighted position that can land between your levels, plus legend and distribution. |
Every number below came back from the live API while this page was being written. None of it is illustrative.
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."
| handler | choice | issue_refund | |
| refund_reason | choice | damaged | |
| has_evidence | noul | 0.99 | |
| needs_human | noul | 0.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.
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?"
| b | score 0–3 | 2.99 | |
| c | score 0–3 | 1.06 | |
| d | score 0–3 | 0.10 | |
| a | score 0–3 | 0.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.
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…"
| supported | noul | 0.01 | |
| contradicted | noul | 0.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.
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_bug | noul | 0.97 | |
| mentions_billing | noul | 0.99 | |
| requests_refund | noul | 0.97 | |
| churn_risk | noul | 0.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.
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_date | choice | 11 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.
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."
| clarity | score 0–3 | 0.05 | |
| testability | score 0–2 | 0.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.
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.
Worth checking, since a cache freezes one answer. Three identical live calls:
| run | is_urgent | billing | frustration | latency |
|---|---|---|---|---|
| 1 | 0.95 | 0.89 | 1.05 | 235 ms |
| 2 | 0.95 | 0.85 | 1.04 | 195 ms |
| 3 | 0.95 | 0.86 | 1.04 | 121 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.
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.
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.
| Option | Find one answer | Two sessions writing at once | Evicting old entries |
|---|---|---|---|
| JSON file | load the whole file | loses whichever write lands second | rewrite everything |
| CSV | scan every row | appends survive, edits do not | rewrite everything |
| JSONL | load it all into memory | appends survive | needs compaction, which races |
| XML | parse the whole document | same failure as JSON | rewrite everything |
| SQLite | indexed lookup | built 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
busy_timeout line and that test fails — which is exactly what a JSON file would do, quietly, with no line to delete.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.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.
A key belongs out of every file that gets shared. Here is how jev-bridge keeps it there.
~/.jev-bridge/.env at chmod 600, in a directory the bridge creates at 0700.TYPESAFE_API_KEY= line of a key file is read, and a key must be one run of printable characters. A file holding anything else is refused with an error that does not quote it — which is how a key with a line break in it used to end up in an error message.--selftest prints the key's length and first eight characters, so you can tell which key loaded — and nothing more..gitignore excludes every .env and every database file, so a key placed in a clone cannot be committed by accident.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.
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.
| Question | Where 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.
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:
| Mutation | Caught |
|---|---|
| Cache key stops ignoring key order | yes |
| Cache key includes the caller's question ids | yes |
| Score levels get sorted, losing their order | yes |
| Answers returned without remapping to the caller's ids | yes |
| TTL never expires an entry | yes |
| Failed calls get cached | yes |
| A moved model alias goes undetected | yes |
| Eviction by age instead of by use | yes |
busy_timeout removed | yes |
| Question validation skipped | yes |
| Question text stored in the cache instead of a hash | yes |
| History written on the answer's path, or on the next tick instead of when idle | yes |
meta keeping the state | yes |
| Reviewed calls pruned by age | yes |
| Certainty taken from the most sure answer | yes |
| The dashboard skipping its Host check, or its token | yes |
| A replay ignoring the reviewer's expected answer | yes |
| A key with a line break in it reaching the request | yes |
| A neighbouring variable in the key file taken for the key | yes |
| The key sent over plain HTTP, or a redirect followed | yes |
| Database files created readable by everyone | yes |
| A cleared history left readable in the file's free pages | yes |
| The dashboard's Content-Security-Policy on the page alone | yes |
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.
jev_usage reports it; so does --stats.jev_ask declares an outputSchema and returns structuredContent, so the client gets data rather than a JSON string to re-read.jev-latest starts resolving elsewhere, old entries stop being served instead of silently outliving the release.node:sqlite. The README tells you to register an absolute interpreter path; if the bridge still meets an old one, it falls back to an in-memory cache instead of failing to start.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.
"Does this convey urgency?" and "does this convey urgency? " need not.jev-latest moves to a new version. Replaying the evaluation set at that moment would catch a regression without anyone remembering to look.# 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).