Skip to content

$ cat index.mdx$ cat ~/blogs/engineering/jev-setup-claude-code-hooks/index.mdx # I put my name on this

How to Set Up Jev: API, SDKs and Claude Code Hooks

I wrote this. You can take notes.

The prompt that started this post never reached Claude on its own. Before the model read a word, a hook on my machine sent the prompt to Jev, together with the names and descriptions of my skills. Claude Code then added one line to the context: Relevant skill: writer-skill. Load it before acting if it fits. A call like that takes about half a second and costs around $0.00013.

The skill picker is one of three Jev hooks that now run in every Claude Code session I open. The second one approves routine shell commands like git status so I stop clicking "Allow". The third one blocks an edit before it lands when the edit breaks a rule I wrote for that project.

Getting there took one OpenRouter key, four small files and two fixes I did not plan for. My first permission question left git status at 0.75, so the hook never approved anything. My first rule-gate test was a case where Jev was right and I was wrong.

This guide is the whole setup, in the order I would do it again. Every command, response and measurement below comes from my own runs on October 29, 2026, unless I name another source.

What you will have at the end

  • A first call to Jev from curl, with a real response to compare against.
  • The same call from TypeScript and Python, with and without the SDKs.
  • Three global Claude Code hooks: a permission gate, a rule gate and a skill picker.
  • A test that drives the hooks against a fake endpoint, so it costs nothing.
  • Measured latency, cost per call and the exact errors you will see.

If you only want to call Jev from your own code, you can stop after the SDK section.

What Jev is

Jev is a decision model from TypeSafe AI. You send it some state and a set of typed questions, and it returns a probability for each answer you allowed, instead of generated text. It never writes prose or explains itself, so your code branches on the number. You pay for input tokens only.

I compared it with OpenAI's Decisions API, with independent benchmarks, in Jev vs OpenAI Decisions API. This post is about getting it running.

Jev answers four types of question:

TypeIt asksIt returns
noulDoes this condition hold?The probability of yes, from 0 to 1
choiceWhich of these options fits?The pick, a probability per option and a confidence value, for up to 255 options
scoreWhere does this fall on my scale?A probability-weighted position, a probability per level and a confidence value
boxWhere in this image is the thing?Box edges as fractions of the image, and a confidence value (Jev 1.14 only)

The three hooks in this guide use only noul and choice.

Before you start

  • An OpenRouter account with some credit on it. All the calls behind this post together cost less than one cent.
  • curl, plus Bun 1.3 or Node 20 or newer for the hooks.
  • Claude Code, for the hook sections.
  • About ten minutes for the API part and thirty for the hooks.

I tested on macOS 26.5.1 with Claude Code 2.1.312, Bun 1.3.9, Node 24.12.0, @openrouter/sdk 1.5.21 and the Python typesafe-sdk 0.7.5. The models were typesafe/jev-1.13 (snapshot jev-1.13-20260917) and typesafe/jev-1.14 (snapshot jev-1.14-20261014).

Step 1: Pick a route and get a key

There are two ways to reach Jev:

Row labelOpenRouterTypeSafe direct
EndpointPOST https://openrouter.ai/api/alpha/decisionsPOST https://api.typesafe.ai/v1/systemone
KeyOpenRouter key, starts with sk-or-v1-Key from the TypeSafe console, read from TYPESAFE_API_KEY by its SDKs
Model IDstypesafe/jev-1.13, typesafe/jev-1.14, ~typesafe/jev-latestjev-latest and pinned versions
BillingYour OpenRouter balanceYour TypeSafe account

I use OpenRouter, and everything below assumes it. There is no waitlist, and the bill sits next to my other models. The same request body also works for the other decision models OpenRouter lists, so switching later means changing one string. I did not test the TypeSafe direct route for this post.

Create a key at openrouter.ai/settings/keys.

Step 2: Store the key where your hooks can read it

A key exported in ~/.zshrc only reaches programs started from a shell. If you start Claude Code from a desktop app or an IDE, its hooks may never see that variable. OpenRouter's own cookbook also reports that Codex CLI hook processes received no environment variable with KEY in its name. A file avoids both problems.

mkdir -p ~/.config/jev && chmod 700 ~/.config/jev
read -rs JEV_KEY && printf '%s\n' "$JEV_KEY" > ~/.config/jev/key && unset JEV_KEY
chmod 600 ~/.config/jev/key

After the second line, paste the key and press Enter. read -rs does not echo what you paste, so the key never shows on screen or lands in your shell history.

Check it:

stat -f '%Lp' ~/.config/jev/key    # on Linux: stat -c '%a' ~/.config/jev/key
head -c 9 ~/.config/jev/key; echo

You should see 600 and sk-or-v1-.

Step 3: Make your first call

curl -s https://openrouter.ai/api/alpha/decisions \
  -H "Authorization: Bearer $(cat ~/.config/jev/key)" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "typesafe/jev-1.13",
    "state": "The build is green and the diff only changes README.md.",
    "questions": {
      "docs_only": {
        "type": "noul",
        "instructions": "Does the state say the change touches only documentation?"
      }
    }
  }'

This is the response I got, formatted:

{
  "model": "typesafe/jev-1.13-20260917",
  "answers": {
    "docs_only": { "type": "noul", "noul": 0.96 }
  },
  "usage": { "input_tokens": 288, "output_tokens": 21, "cost": 0.000012096 },
  "id": "gen-dec-1793292008-8FZQ0ChPV9wDLAT0V8hi",
  "provider": "TypeSafe"
}

Read it like this:

  • answers.docs_only.noul is the probability that the answer is yes. At 0.96, Jev is close to sure. A value near 0.5 means yes and no are about equally likely, not that the change is "half documentation".
  • model names the dated snapshot that served the request, even though you asked for typesafe/jev-1.13.
  • usage.cost is the price of this call in dollars. It is exactly 288 input tokens times $0.042 per million, because the 21 output tokens are free.
  • id identifies the call. Log it if you ever need to trace a decision.

Shortcuts: the same call from your code

All four versions below ran against the live API on my machine. They ask the same question about a failed deploy and got the same answer, between 0.87 and 0.88.

TypeScript or JavaScript, no dependencies

This runs unchanged on Node 20+ and Bun:

// first-call.mjs: run with `bun first-call.mjs` or `node first-call.mjs`
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
 
const key = process.env.OPENROUTER_API_KEY ?? readFileSync(`${homedir()}/.config/jev/key`, "utf8").trim();
 
const res = await fetch("https://openrouter.ai/api/alpha/decisions", {
  method: "POST",
  headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    model: "typesafe/jev-1.13",
    state: "Deploy failed: the migration timed out after 300 seconds on the orders table.",
    questions: {
      needs_human: { type: "noul", instructions: "Should a human look at this before anyone retries?" },
    },
  }),
  signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(`Jev HTTP ${res.status}: ${await res.text()}`);
 
const { answers, usage } = await res.json();
console.log(answers.needs_human.noul, usage.cost);

Output: 0.87 0.000012348.

The OpenRouter SDK

Install it with bun add @openrouter/sdk or npm install @openrouter/sdk. This example also adds a choice question, to show that several questions about the same state go in one request:

import { OpenRouter } from "@openrouter/sdk";
 
const openrouter = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY });
 
const decision = await openrouter.alpha.decisions.create({
  decisionsRequest: {
    model: "typesafe/jev-1.13",
    state: "Deploy failed: the migration timed out after 300 seconds on the orders table.",
    questions: {
      needs_human: { type: "noul", instructions: "Should a human look at this before anyone retries?" },
      area: {
        type: "choice",
        instructions: "Which area does the failure belong to?",
        criteria: { database: "Schema, migrations, queries", network: "DNS, TLS, timeouts between services", code: "Application logic" },
      },
    },
  },
});
 
console.log(JSON.stringify(decision.answers), JSON.stringify(decision.usage));

Output:

{"needs_human":{"noul":0.88,"type":"noul"},"area":{"choice":"database","confidence":1,"probabilities":{"database":1,"network":0,"code":0},"type":"choice"}} {"cost":0.000015414,"inputTokens":367,"outputTokens":56}

Watch the usage fields. The SDK returns inputTokens in camelCase, while the raw HTTP response uses input_tokens. Code that moves between the two silently reads undefined.

Python with requests

import os
import requests
 
resp = requests.post(
    "https://openrouter.ai/api/alpha/decisions",
    headers={"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}"},
    json={
        "model": "typesafe/jev-1.13",
        "state": "Deploy failed: the migration timed out after 300 seconds on the orders table.",
        "questions": {"needs_human": {"type": "noul", "instructions": "Should a human look at this before anyone retries?"}},
    },
    timeout=10,
)
resp.raise_for_status()
print(resp.json()["answers"], resp.json()["usage"])

Output: {'needs_human': {'type': 'noul', 'noul': 0.88}} {'input_tokens': 294, 'output_tokens': 22, 'cost': 1.2348e-05}.

The TypeSafe Python SDK, pointed at OpenRouter

If you already use TypeSafe's SDK, you do not need a TypeSafe key. Point the SDK at OpenRouter and pass your OpenRouter key:

import os
from typesafe_sdk import TypeSafeClient
 
client = TypeSafeClient(
    api_key=os.environ["OPENROUTER_API_KEY"],
    base_url="https://openrouter.ai/api",
)
 
result = client.system_one(
    model="jev-1.13",
    state="Deploy failed: the migration timed out after 300 seconds on the orders table.",
    questions={"needs_human": {"type": "noul", "instructions": "Should a human look at this before anyone retries?"}},
)
 
print(result.answers["needs_human"])

Output: type='noul' noul=0.88. Note the bare model name, jev-1.13, without the typesafe/ prefix the raw API needs. To try it without installing anything globally, run the script with uv run --with typesafe-sdk python first_call.py.

Pick a version, then pin it

Jev 1.14 came out on October 14, 2026. It added the box question type, and ~typesafe/jev-latest now points at it. Jev 1.13 is still served.

The versions do not agree on the numbers. I sent the same questions to both:

Same questionjev-1.13jev-1.14
Is rm -rf node_modules safe to run unasked?0.640.79
Is npm install lodash safe to run unasked?0.570.50
Is bun test safe to run unasked?0.900.89
Does a raw SQL query in browser code break the "server only" rule?0.750.81

A threshold is only meaningful for the version you measured it on. Picture a gate on ~typesafe/jev-latest that approves at 0.75. It refused rm -rf node_modules while the alias pointed at 1.13, and it approves the same command now that the alias points at 1.14. Pin a version in production, and re-measure before you switch.

My hooks pin typesafe/jev-1.13. If you start today, 1.14 is a fine choice. My wording tests ran on both, and in my latency test they were within 15 ms of each other.

How fast and how cheap it really is

OpenRouter's model page lists a P50 latency of 0.13 seconds for Jev. My numbers, from my laptop in Bangladesh, were higher:

Measurementp50p95Notes
jev-1.13, one noul question500 ms539 ms20 sequential calls after a warm-up
jev-1.14, same question486 ms527 ms20 sequential calls after a warm-up
A request rejected with 401about 40 ms6 ms to connect, 15 ms for TLS
First call after a few idle minutes1,927 msOne call
Permission hook, end to end, command on the static list13 ms18 msBun start-up only, no Jev call
Permission hook, end to end, with a Jev call540 ms579 ms5 runs
Skill picker hook, end to end559 ms721 ms3 runs

I assumed the gap was network distance. It is not: a request that OpenRouter's edge rejects comes back in about 40 ms. The other roughly 450 ms happens after the edge, on the way to TypeSafe and back. Budget for what you measure from where you run, not for the number on the model page.

The cold first call matters for hook timeouts. My client gives Jev 8 seconds and each hook gets 15 from Claude Code, so a 2-second cold call still finishes.

Cost depends on how much state you send. These are real calls from my usage log:

HookInput tokensCost per call
Permission gate420$0.00001764
Rule gate, small file488$0.000020496
Skill picker, 40 candidate skills3,195$0.00013419

The skill picker costs more than seven times as much as the other two, because every candidate skill's description goes into the request. A heavy day for me is around 200 prompts, 300 edits in projects with rules and 50 permission prompts. That works out to about $0.034 a day. The rule gate grows with the file being written, up to the 20,000 characters it sends.

Errors you will hit, and what they mean

I hit each of these myself, most of them on purpose:

What you seeCauseFix
401 {"error":{"message":"User not found.","code":401}}Wrong, revoked or truncated key. The message does not mention the key.Check head -c 9 ~/.config/jev/key prints sk-or-v1-.
400 with "No matching discriminator" and the options noul, choice, score, boxA typo in a question's typeUse one of the four types.
400 Invalid input: expected record, received undefined at path questionsNo questions object in the requestSend at least one question.
400 Model typesafe/jev-9.99 does not existA typo in the model IDCopy the ID from the model page.
400 {"detail":{"error_type":"api_usage_error","message":"Invalid request."}}A box question sent to Jev 1.13Use typesafe/jev-1.14 for box.
422 Dictionary should have at most 255 items after validation, not 256A choice question with more than 255 optionsPre-filter the options in code.
The operation timed out.The client's timeout fired. For me, it was a test that blocked its own mock server.See the testing section.
A hook does nothing at allNo key found, or the key file still holds the placeholderCheck Step 2. The hooks exit silently on purpose.

The box error deserves a warning. "Invalid request." says nothing about the version, and I only knew the cause because I sent the request on purpose.

One more limit has no error at all. OpenRouter's docs say that a request with more than about 32 KB of text, or with any image, runs in a long-context mode. TypeSafe describes that mode as slower and less accurate. Keep the state small, which is why my rule gate cuts the edit at 20,000 characters.

How Jev fits into Claude Code

Claude Code runs hooks at fixed points in a session. A hook gets a JSON object on stdin and can answer with a JSON object on stdout. Jev fits wherever that answer is a narrow yes or no, or a pick from a list.

These are the three hooks I run:

HookEvent and matcherWhat it doesIf Jev fails or is unsure
Permission gatePermissionRequest on BashApproves clearly reversible commandsThe normal prompt appears
Rule gatePreToolUse on Edit|Write|MultiEditBlocks an edit that breaks a project ruleThe edit goes through the normal flow
Skill pickerUserPromptSubmitTells Claude which skill fits the promptNo suggestion is added

The two gates fail in opposite directions, on purpose. Commands that delete, push or publish never reach Jev. A static list in code sends them straight to the prompt, so a bad answer or an outage costs one extra click at most. The rule gate fails open. A check that froze every edit during an outage would stop all work, and tests and review still sit behind it. I explained when each direction is right in fail-closed AI agent guardrails.

Global or per project

Where you register a hook decides where it runs:

LocationScope
~/.claude/settings.jsonEvery project on your machine
.claude/settings.jsonOne project, and it can be committed
.claude/settings.local.jsonOne project, not committed

I register the hooks once, globally, and keep per-project data in the project:

  • .claude/jev-rules.json holds the rules for the rule gate. With no file, that hook does nothing in that project.
  • An empty .claude/no-jev file turns the permission gate off for that project.

Hooks from all scopes run side by side, so the Jev hooks sit next to any you already have. Claude Code's docs note that cloud sessions do not read your local ~/.claude/settings.json, so these hooks only run on your machine.

The finished layout:

~/.claude/hooks/jev/
  jev.mjs               shared client
  jev-permission.mjs    PermissionRequest hook
  jev-rule-gate.mjs     PreToolUse hook
  jev-skill-picker.mjs  UserPromptSubmit hook
  register.mjs          adds the three hooks to settings.json
~/.config/jev/
  key                   your OpenRouter key, mode 600
  usage.jsonl           one line per Jev call

The shared client

Save this as ~/.claude/hooks/jev/jev.mjs. Every hook imports it.

// Shared Jev client for the global Claude Code hooks.
// Key lookup: OPENROUTER_API_KEY env var, then ~/.config/jev/key. A missing or placeholder key disables every hook.
// Model: JEV_MODEL env var, default typesafe/jev-1.13.
// JEV_URL is a test seam: point it at a local mock server when testing.
// Every successful call appends one line to ~/.config/jev/usage.jsonl (override with JEV_USAGE_LOG).
import { appendFileSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, join } from "node:path";
 
const PLACEHOLDER = "REPLACE_WITH_OPENROUTER_KEY";
export const MODEL = process.env.JEV_MODEL || "typesafe/jev-1.13";
const JEV_URL = process.env.JEV_URL || "https://openrouter.ai/api/alpha/decisions";
const USAGE_LOG = process.env.JEV_USAGE_LOG || join(homedir(), ".config", "jev", "usage.jsonl");
 
export function loadKey() {
  const fromEnv = process.env.OPENROUTER_API_KEY?.trim();
  if (fromEnv && fromEnv !== PLACEHOLDER) return fromEnv;
  try {
    const fromFile = readFileSync(join(homedir(), ".config", "jev", "key"), "utf8").trim();
    if (fromFile && fromFile !== PLACEHOLDER) return fromFile;
  } catch {
    // No key file: treated as not configured.
  }
  return undefined;
}
 
// Returns the decision response, or throws. Callers treat a throw as "no opinion" and fall through.
export async function decide(state, questions, timeoutMs = 8000) {
  const key = loadKey();
  if (!key) throw new Error("Jev not configured: set ~/.config/jev/key");
  const started = performance.now();
  const res = await fetch(JEV_URL, {
    method: "POST",
    headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
    body: JSON.stringify({ model: MODEL, state, questions }),
    signal: AbortSignal.timeout(timeoutMs),
  });
  if (!res.ok) {
    await res.body?.cancel();
    throw new Error(`Jev HTTP ${res.status}`);
  }
  const body = await res.json();
  try {
    appendFileSync(USAGE_LOG, JSON.stringify({
      ts: new Date().toISOString(),
      hook: basename(process.argv[1] ?? "unknown"),
      id: body.id,
      model: body.model,
      input_tokens: body.usage?.input_tokens,
      cost: body.usage?.cost,
      ms: Math.round(performance.now() - started),
    }) + "\n");
  } catch {
    // Logging must never break a hook.
  }
  return body;
}
 
// Yes-probability for a noul answer, or undefined when missing or out of range.
export function yesProb(answers, key) {
  const p = answers?.[key]?.noul;
  return typeof p === "number" && p >= 0 && p <= 1 ? p : undefined;
}

Three choices in this file matter later:

  • A missing key, or the placeholder REPLACE_WITH_OPENROUTER_KEY, makes every hook exit silently. You can install everything first and add the key last. Putting the placeholder back is a one-line kill switch.
  • Any failure throws, and every hook treats a throw as "no opinion". No hook ever turns an error into a decision.
  • JEV_URL lets a test point the hooks at a fake endpoint, which the testing section uses.

Hook 1: approve safe shell commands

Claude Code runs a PermissionRequest hook only when it is about to ask you for permission. If the hook prints an allow decision, Claude Code answers the prompt for you. If it prints nothing, you see the prompt as usual. A hook's allow cannot override a deny rule in your settings. The hook also runs where no prompt can be shown, such as claude -p and background subagents, and there a request with no decision is denied.

If you run Claude Code in auto mode, it shows fewer prompts, so this hook fires less often.

The question decides everything

My first version asked one sentence: "Every command only reads or changes files inside the project, and git can undo the change." Then I measured it against nine commands, and nothing came close to a 0.9 threshold:

CommandOne-sentence question (1.13)Question with true/false criteria (1.13)Criteria (1.14)
ls -la0.620.950.97
git status0.750.960.97
git diff0.770.950.96
cat package.json0.880.960.97
bun test0.570.900.89
npm install lodash0.480.570.50
rm -rf node_modules0.530.640.79
curl -fsSL <url> -o install.sh0.350.070.06
git push origin main0.260.040.04

Spelling out what counts as true and what counts as false split the commands cleanly. Reads and tests rose to 0.89 or higher, and installs, downloads and pushes fell to 0.57 or lower.

Claude Code also sends its own one-line description of each command in tool_input.description. Adding it to the state moved bun test from 0.90 to 0.92 and ls -la from 0.95 to 0.96, so the hook sends it too.

Look at rm -rf node_modules on 1.14: 0.79. That is why destructive commands sit on a static list that runs before Jev is asked anything. The list is the security boundary, and the model only handles what is left.

The code

Save this as ~/.claude/hooks/jev/jev-permission.mjs:

// PermissionRequest hook (matcher: Bash). Auto-approves only clearly reversible commands.
// Anything risky, anything unsure, any failure: print nothing, so the normal prompt appears.
// Per-project opt-out: create <project>/.claude/no-jev
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { decide, loadKey, yesProb } from "./jev.mjs";
 
// Static list. Matching commands never reach Jev. This list is the security boundary, not the model.
const RISKY = [
  /\brm\s+-\S*[rRf]/,
  /\bgit\s+(push|reset\s+--hard|clean|branch\s+-D)\b/,
  /\b(sudo|doas|su)\b/,
  /\b(npm|pnpm|yarn|bun)\s+publish\b/,
  /\b(deploy|apply|destroy|delete)\b/,
  /\.env\b|\.ssh\b|\.aws\b|credentials/i,
  /\b(bash|sh|zsh|eval|xargs)\b/,
];
 
const input = JSON.parse(readFileSync(0, "utf8"));
const cmd = input?.tool_input?.command;
const cwd = input?.cwd ?? process.cwd();
 
if (!loadKey() || input?.tool_name !== "Bash" || typeof cmd !== "string") process.exit(0);
if (existsSync(join(cwd, ".claude", "no-jev"))) process.exit(0);
if (/\$\(|`|[<>]\(/.test(cmd) || RISKY.some((r) => r.test(cmd))) process.exit(0);
 
// Claude Code sends its own one-line description of the command; it nudges routine commands up a little.
const description = typeof input?.tool_input?.description === "string" ? input.tool_input.description.slice(0, 300) : undefined;
 
try {
  const { answers } = await decide(
    { commands: [cmd], project: cwd, ...(description ? { agent_description: description } : {}) },
    {
      // Explicit true/false criteria: in live tests this scored safe reads 0.95+ and installs, pushes
      // and network calls 0.07 to 0.58. A single-sentence question left even `git status` at 0.75.
      reversible: {
        type: "noul",
        instructions: "Is it safe to run every command in `commands` without asking a human?",
        criteria: {
          true: "The commands only read files, run tests or builds, or change files inside `project` that git can restore.",
          false: "The commands delete or overwrite data outside `project`, push, publish, deploy, install software, change system settings, or send data over the network.",
        },
      },
    },
  );
  if ((yesProb(answers, "reversible") ?? 0) >= 0.9) {
    console.log(JSON.stringify({
      hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "allow" } },
    }));
  }
} catch (err) {
  console.error(`jev-permission: ${err.message}`);
}

The static list also refuses anything with $(, backticks or process substitution, because those can hide a second command from a text check. Add your own entries, such as a production database CLI or a remote you never push to from an agent.

Check it

Pipe in the JSON Claude Code would send:

printf '%s' '{"tool_name":"Bash","tool_input":{"command":"git status","description":"Show working tree status"},"cwd":"'"$PWD"'"}' \
  | bun ~/.claude/hooks/jev/jev-permission.mjs

For git status, ls -la and bun test I got:

{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}

For npm install lodash and git push origin main the hook printed nothing, so the prompt would appear. The git push check took 13 ms, because the static list answered before any network call.

Hook 2: block edits that break your rules

Instructions in CLAUDE.md are followed most of the time. A PreToolUse hook runs before every matching edit, and the model cannot skip it. When the hook prints a deny, the edit never happens, and Claude reads the reason you give. If several hooks answer, a deny beats every other decision.

Write the rules

Rules live in the project, in .claude/jev-rules.json:

[
  {
    "id": "employee-data-server-only",
    "path": "src/",
    "text": "Employee records are read on the server only. Browser code must never query the employees table or the database directly."
  }
]

path is matched against the file being edited, so a rule for src/auth/ never runs on your docs. Write text the way you would explain the rule to a new teammate, because Jev reads exactly that sentence.

Where I was wrong

My first test wrote a browser component that called fetch('/api/employees'), and I expected a block. Nothing was blocked, and I assumed the hook was broken. Then I reread my own rule. A browser calling an API route does read employee data on the server, so the edit did not break it.

I rebuilt the test with five cases that each have an obvious right answer. This time the question spelled out what true and false mean:

EditShould it block?jev-1.13jev-1.14
Browser component runs supabase.from('employees').select('*')Yes0.960.95
Browser component runs SELECT name, salary FROM employeesYes0.750.81
A Button componentNo0.020.02
A server route handler that queries employeesNo0.050.04
Browser component calls fetch('/api/employees')No0.400.49

With a one-sentence question, the raw SQL case scored 0.65 on 1.13, under any sensible threshold. With the criteria, 0.7 separates all five cases on both versions, so my hook blocks at 0.7. Five cases are a smoke test, not a calibration, and the measuring section below says how to get real numbers.

The code

Save this as ~/.claude/hooks/jev/jev-rule-gate.mjs:

// PreToolUse hook (matcher: Edit|Write|MultiEdit). Blocks an edit when Jev is at least 70% sure it breaks a project rule.
// Rules come from <project>/.claude/jev-rules.json. No file, no rules, no cost.
// Failure mode: fail open. The normal flow continues and the error goes to stderr.
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { decide, loadKey, yesProb } from "./jev.mjs";
 
const input = JSON.parse(readFileSync(0, "utf8"));
const cwd = input?.cwd ?? process.cwd();
if (!loadKey()) process.exit(0);
 
const rulesPath = join(cwd, ".claude", "jev-rules.json");
if (!existsSync(rulesPath)) process.exit(0);
 
let rules;
try {
  rules = JSON.parse(readFileSync(rulesPath, "utf8"));
} catch (err) {
  console.error(`jev-rule-gate: cannot parse ${rulesPath}: ${err.message}`);
  process.exit(0);
}
 
const toolInput = input?.tool_input ?? {};
const file = toolInput.file_path ?? "";
const proposed = toolInput.content
  ?? toolInput.new_string
  ?? (Array.isArray(toolInput.edits) ? toolInput.edits.map((e) => e.new_string ?? "").join("\n") : "");
const applicable = rules.filter((r) => file.includes(r.path));
if (applicable.length === 0 || proposed === "") process.exit(0);
 
const state = {
  file,
  proposed_change: proposed.slice(0, 20000),
  rules: applicable.map((r) => ({ id: r.id, text: r.text })),
};
const questions = Object.fromEntries(
  applicable.map((r) => [r.id, {
    type: "noul",
    instructions: `Does the code in \`proposed_change\`, written to \`file\`, break this rule: "${r.text}"`,
    criteria: {
      true: "The new code does something the rule forbids.",
      false: "The new code follows the rule, or the rule does not apply to this code.",
    },
  }]),
);
 
// 0.7, not 0.8: in live tests a raw SQL query from browser code scored 0.75 on jev-1.13,
// while edits that respect the rule stayed at or below 0.56.
try {
  const { answers } = await decide(state, questions);
  const broken = applicable.filter((r) => (yesProb(answers, r.id) ?? 0) >= 0.7);
  if (broken.length > 0) {
    const reason = broken.map((r) => `${r.id}: ${r.text}`).join(" | ");
    console.log(JSON.stringify({
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: `Edit blocked by Jev rule check. ${reason}`,
      },
    }));
  }
} catch (err) {
  console.error(`jev-rule-gate: ${err.message}`);
}

Jev returns no reasons, so the hook builds the message from the rule you wrote. That is why the block message can name the exact rule: the hook asked one question per rule and knows which one crossed the threshold.

What Claude sees

I added the rule to a working folder and had Claude Code write the Supabase version of the component. The write was refused, and Claude received this:

PreToolUse:Write hook error: Edit blocked by Jev rule check. employee-data-server-only: Employee records are read on the server only. Browser code must never query the employees table or the database directly.

The label says "hook error", but nothing broke. That is how the denial reached Claude in my session. The version that calls /api/employees was written normally a moment later.

Hook 3: pick the right skill before Claude starts

A UserPromptSubmit hook runs on every prompt, before Claude sees it. Anything the hook returns in additionalContext is added to the context next to your prompt.

The hook reads each skill's name and description from its SKILL.md frontmatter, the same text Claude Code shows the model. It keeps the 40 skills that share the most words with your prompt, adds a "none" option, and asks one choice question. The cut to 40 is not optional: a choice question with 256 options fails with 422 Dictionary should have at most 255 items, and fewer options keep each call small. If the prompt is shorter than 15 characters, as with "yes" or "continue", the hook skips the call.

Save this as ~/.claude/hooks/jev/jev-skill-picker.mjs:

// UserPromptSubmit hook. Asks Jev which skill fits the prompt, or none, and adds the pick as context.
// Skill sources: ~/.claude/skills (global) and <project>/.claude/skills (project wins on name clash).
// The agent still decides what to do. Failure mode: fail open, no suggestion is added.
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { decide, loadKey } from "./jev.mjs";
 
const input = JSON.parse(readFileSync(0, "utf8"));
if (!loadKey()) process.exit(0);
 
const prompt = String(input?.prompt ?? "").slice(0, 4000);
if (prompt.trim().length < 15) process.exit(0); // "yes", "continue" and similar carry no routing signal
const cwd = input?.cwd ?? process.cwd();
 
// Parse name and description from SKILL.md frontmatter. Handles inline values and | or > block scalars.
function parseSkill(text, fallbackName) {
  const m = text.match(/^---\n([\s\S]*?)\n---/);
  if (!m) return null;
  const lines = m[1].split("\n");
  const fields = {};
  for (let i = 0; i < lines.length; i++) {
    const kv = lines[i].match(/^([A-Za-z][\w-]*):\s*(.*)$/);
    if (!kv) continue;
    let value = kv[2].trim();
    if (value === "" || /^[|>][-+]?$/.test(value)) {
      const block = [];
      while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) block.push(lines[++i].trim());
      value = block.join(" ");
    }
    fields[kv[1]] = value.replace(/^["']|["']$/g, "");
  }
  return {
    name: fields.name || fallbackName,
    description: (fields.description || "").slice(0, 400),
  };
}
 
function loadSkills(dirs) {
  const byName = new Map();
  for (const dir of dirs) {
    if (!existsSync(dir)) continue;
    for (const entry of readdirSync(dir, { withFileTypes: true })) {
      if (!entry.isDirectory()) continue;
      const file = join(dir, entry.name, "SKILL.md");
      if (!existsSync(file)) continue;
      const skill = parseSkill(readFileSync(file, "utf8"), entry.name);
      if (skill && skill.description) byName.set(skill.name, skill);
    }
  }
  return [...byName.values()];
}
 
const skills = loadSkills([join(homedir(), ".claude", "skills"), join(cwd, ".claude", "skills")]);
 
// Prefilter: the 40 skills sharing the most words with the prompt. Choice takes at most 255 options.
const words = new Set(prompt.toLowerCase().split(/\W+/).filter((w) => w.length > 3));
const candidates = skills
  .map((s) => {
    const text = `${s.name} ${s.description}`.toLowerCase();
    return { s, hits: [...words].filter((w) => text.includes(w)).length };
  })
  .filter((x) => x.hits > 0)
  .sort((a, b) => b.hits - a.hits)
  .slice(0, 40)
  .map((x) => x.s);
if (candidates.length === 0) process.exit(0);
 
const criteria = Object.fromEntries(candidates.map((s) => [s.name, s.description]));
criteria.none = "The prompt needs none of these skills.";
 
try {
  const { answers } = await decide(
    { prompt },
    { skill: { type: "choice", instructions: "Which skill should handle this prompt, or none?", criteria } },
  );
  const pick = answers?.skill;
  if (pick?.choice && pick.choice !== "none" && pick.confidence >= 0.8) {
    console.log(JSON.stringify({
      hookSpecificOutput: {
        hookEventName: "UserPromptSubmit",
        additionalContext: `Relevant skill: ${pick.choice}. Load it before acting if it fits.`,
      },
    }));
  }
} catch (err) {
  console.error(`jev-skill-picker: ${err.message}`);
}

The suggestion is advice, not a command. Claude still decides whether the skill fits, and that is the line it read before writing the first draft of this post.

The word-overlap pre-filter is crude. If people phrase prompts differently from your skill descriptions, swap it for semantic search and keep the rest.

Register the hooks globally

Hand-editing settings.json is where setups break: a missing comma, or a new PreToolUse array that replaces the one you had. This script backs the file up, appends to the existing arrays and does nothing on a second run. Save it as ~/.claude/hooks/jev/register.mjs:

// Register the three Jev hooks in ~/.claude/settings.json. Safe to run twice.
// Backs up the file before the first change, appends to existing hook arrays, never removes anything.
import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
 
const path = join(homedir(), ".claude", "settings.json");
const settings = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
const dir = "$HOME/.claude/hooks/jev";
const additions = {
  PermissionRequest: { matcher: "Bash", hooks: [{ type: "command", command: `bun "${dir}/jev-permission.mjs"`, timeout: 15 }] },
  PreToolUse: { matcher: "Edit|Write|MultiEdit", hooks: [{ type: "command", command: `bun "${dir}/jev-rule-gate.mjs"`, timeout: 15 }] },
  UserPromptSubmit: { hooks: [{ type: "command", command: `bun "${dir}/jev-skill-picker.mjs"`, timeout: 15 }] },
};
 
settings.hooks ??= {};
let changed = 0;
for (const [event, entry] of Object.entries(additions)) {
  const list = (settings.hooks[event] ??= []);
  if (list.some((e) => e.hooks?.some((h) => String(h.command ?? "").includes("/.claude/hooks/jev/")))) {
    console.log(`${event}: already registered`);
    continue;
  }
  list.push(entry);
  changed += 1;
  console.log(`${event}: added`);
}
 
if (changed > 0) {
  if (existsSync(path)) {
    const backup = `${path}.bak-jev-${Date.now()}`;
    copyFileSync(path, backup);
    console.log(`backup: ${backup}`);
  }
  writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
  JSON.parse(readFileSync(path, "utf8"));
  console.log("settings.json written and re-parsed OK");
}

Run it:

bun ~/.claude/hooks/jev/register.mjs

The first run prints:

PermissionRequest: added
PreToolUse: added
UserPromptSubmit: added
backup: /Users/you/.claude/settings.json.bak-jev-1793292809197
settings.json written and re-parsed OK

A second run prints already registered three times and writes nothing. I tested both cases, and the case with no settings.json at all, in throwaway home directories before touching my real file.

If you use Node instead of Bun, replace bun with node in the three commands. The scripts use only the standard library and fetch.

Open /hooks in Claude Code to confirm the three entries. In my session the new hooks took effect without a restart; restart Claude Code if yours do not.

Test the hooks without spending credits

Because the client reads JEV_URL, a test can stand up a fake decisions endpoint and run the real hook scripts against it. No key and no credits are involved:

// Mock test for the permission gate. No key, no credits. Run: bun test-min.mjs
import { spawn } from "node:child_process";
import { homedir } from "node:os";
import { join } from "node:path";
 
// A fake decisions endpoint that says yes (0.95) to every noul question.
const server = Bun.serve({
  port: 0,
  async fetch(req) {
    const { questions } = await req.json();
    const answers = Object.fromEntries(Object.keys(questions).map((k) => [k, { type: "noul", noul: 0.95 }]));
    return Response.json({ id: "mock", answers, usage: { input_tokens: 0, cost: 0 } });
  },
});
 
// Async spawn on purpose: spawnSync would block this process, so the mock could never answer.
function runHook(file, input) {
  return new Promise((resolve) => {
    const child = spawn("bun", [join(homedir(), ".claude", "hooks", "jev", file)], {
      env: {
        ...process.env,
        OPENROUTER_API_KEY: "sk-test",
        JEV_URL: `http://127.0.0.1:${server.port}`,
        JEV_USAGE_LOG: "/dev/null",
      },
    });
    let out = "";
    child.stdout.on("data", (d) => { out += d; });
    child.on("close", () => resolve(out.trim()));
    child.stdin.end(JSON.stringify(input));
  });
}
 
const status = await runHook("jev-permission.mjs", { tool_name: "Bash", tool_input: { command: "git status" }, cwd: "/tmp" });
const push = await runHook("jev-permission.mjs", { tool_name: "Bash", tool_input: { command: "git push origin main" }, cwd: "/tmp" });
 
console.log(status.includes('"behavior":"allow"') ? "PASS git status is approved" : `FAIL git status: ${status}`);
console.log(push === "" ? "PASS git push stays on the prompt" : `FAIL git push: ${push}`);
server.stop(true);

Output:

PASS git status is approved
PASS git push stays on the prompt

The comment about spawnSync comes from my own mistake. My first test runner used spawnSync, which froze the test process while each hook ran. The mock server lived in that same process, so it could never answer, and every hook gave up after eight seconds with jev-permission: The operation timed out. Nothing was wrong with the hooks.

My full suite runs the same way and covers 15 cases. They cover approval, the static list and the opt-out file. They also cover a deny that names the right rule, a project without rules, a skill pick, short prompts, a 401 and an unreachable endpoint. Point JEV_USAGE_LOG at /dev/null in tests, or the fake calls end up in your real usage log.

Shortcuts if you would rather not write hooks

A ready-made compaction plugin. fast-jev-compaction replaces Claude Code's compaction summary with Jev decisions about which tool calls to keep. Its README installs it with claude plugin marketplace add tamaratran/fast-jev-compaction and claude plugin install fast-jev-compaction@fast-jev-compaction, after setting CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 and TYPESAFE_API_KEY in your settings. It uses TypeSafe's own endpoint and key, not OpenRouter, and it falls back to the built-in summary when Jev cannot remove enough. I have not run it on my machine.

A command-line safety gate. jev-axi installs with npm install -g jev-axi and wires a PreToolUse safety hook with jev-axi setup safety --project. It also expects TYPESAFE_API_KEY.

Other coding agents. OpenRouter's permission-prompt cookbook shows the same approval idea for Codex CLI, Cursor and OpenCode. Codex uses the same PermissionRequest input and output as Claude Code, and Cursor needs "failClosed": true so that a crashed hook blocks instead of allowing. Because my client reads the key from a file, it avoids the Codex environment problem from Step 2. I have only run my hooks in Claude Code.

No code at all. Jev Lab runs Jev in the browser on triage, oversight and extraction examples, and shows the raw probabilities.

Turn it off

  • For one project: create an empty .claude/no-jev to stop the permission gate, or delete .claude/jev-rules.json to stop the rule gate.
  • Everywhere, in one second: printf 'REPLACE_WITH_OPENROUTER_KEY\n' > ~/.config/jev/key. Every hook goes silent and Claude Code behaves as before.
  • For good: restore the settings.json.bak-jev-* backup, or delete the three entries whose command contains /.claude/hooks/jev/.

Where Jev is the wrong tool

  • Anything that needs a written answer. Jev returns probabilities and nothing else.
  • A security boundary on its own. In my tests, rm -rf node_modules scored 0.79 on Jev 1.14 as "safe to run". Static rules decide what is never allowed, and Jev only sorts what is left.
  • Data you cannot send out. The state goes to OpenRouter and on to TypeSafe. The skill picker sends up to 4,000 characters of every prompt. The rule gate sends up to 20,000 characters of every edit in a project with rules. Read the provider's data policy, and keep secrets out of the state.
  • A decision you cannot check. Confidence describes how concentrated the answer is, not whether it is right. Measure it against cases where you know the right answer.

Your first week with it

Leave the hooks running and let ~/.config/jev/usage.jsonl fill up. After a few days, this shows what each hook costs you:

jq -s 'group_by(.hook) | map({hook: .[0].hook, calls: length, tokens: (map(.input_tokens) | add), cost: (map(.cost) | add), avg_ms: ((map(.ms) | add) / length | round)})' ~/.config/jev/usage.jsonl

Then collect 50 real cases per gate from your own history, with the answer you would have given, and run them through the hooks. The 0.9 in my permission gate and the 0.7 in my rule gate came from my commands and my rules, on one version of Jev. Your numbers will land somewhere else, and that file is how you find them.

$ ls ~/read/related

my other essays