{"slug": "jev-decides-claude-writes-building-a-model-router-for-claude-code", "title": "Jev Decides, Claude Writes: Building a Model Router for Claude Code", "summary": "A developer built a model router for Claude Code that uses TypeSafe's Jev, a non-generative \"System One\" model, to classify each incoming message and route small jobs to a cheaper Claude model instead of the largest configured one. Jev answers typed questions in fixed shapes — choice, score and noul — returning probabilities and a confidence, with calls taking about 300–600 ms round trip at $0.042 per million input tokens and free output tokens; the API is a single POST endpoint to api.typesafe.ai/v1/systemone, where jev-latest currently points to jev-1.14.0. The author demonstrated Jev by scoring a fabricated sales email on lead strength, email kind and whether it needs a personal reply, and notes parts of the router still don't work as intended.", "body_md": "# Jev Decides, Claude Writes: Building a Model Router for Claude Code\n\nMost of what I type into Claude Code is small. I ask it to rename a variable, look something up, or add \"yes, but shorter\". Every one of those messages still goes to the biggest model I have configured. That's like hiring a senior architect to change a light bulb.\n\nThis post covers how I wired [TypeSafe's **Jev**](https://typesafe.ai/?ref=corti.com) model into Claude Code, and how I used it to build a **model router**. Jev sizes up each message and sends small jobs to a smaller, cheaper Claude model.\n\nI cover what the router does, why I built it this way, and the parts that still don't work the way I'd like.\n\n## What Jev is (and isn't)\n\nJev is TypeSafe's first \"System One\" model. The name comes from the fast, intuitive system in\n\nKahneman's two-system model of thinking. Jev doesn't generate text. You give it some **state** (text, or\n\nJSON describing it) and a set of **typed questions**. It answers each question in one of a few\n\nfixed shapes:\n\n| Type | What it answers | What comes back | \n|---|---|---|\n| `choice` | Pick one option from a list | the top option, a probability per option, and a `confidence` | \n| `score` | Place the content on an ordered scale | a weighted position, a probability per level, and a `confidence` | \n| `noul` | How likely is this statement true? | one probability, `p(yes)` | \n\nThere's also `box`, which finds things in images, but I didn't need it here.\n\nWhy it fits this job:\n\n- **Fast.** My calls took about 300–600 ms round trip.\n- **Cheap.** $0.042 per*million* input tokens, and output tokens are free.\n- **Calibrated.** Answers come with probabilities, so your code can decide what to do when Jev\nisn't sure.\n\nThe whole API is one endpoint:\n\n```\nPOST https://api.typesafe.ai/v1/systemone\nAuthorization: Bearer $TYPESAFE_API_KEY\n{\n  \"model\": \"jev-latest\",\n  \"state\": { \"message\": \"...\" },\n  \"questions\": {\n    \"size\": {\n      \"type\": \"choice\",\n      \"instructions\": \"...\",\n      \"criteria\": { \"option_a\": \"description\", \"option_b\": \"description\" }\n    }\n  }\n}\n```\n\n`jev-latest` currently points to `jev-1.14.0`.\n\n## First contact: sorting a sales email\n\nBefore building anything, I wanted to see Jev work on something concrete. I made up a sales email from a Head of Operations. It asked for 300 seats, said the Q4 budget was approved and the decision was due by Nov 30, and asked for pricing and a call. I asked Jev three questions in one request:\n\n```\n\"questions\": {\n  \"lead_strength\": {\n    \"type\": \"score\",\n    \"instructions\": \"How strong a sales lead is the sender of `email`?\",\n    \"criteria\": [\n      \"No buying interest: the sender is not looking to buy anything\",\n      \"Curious: the sender asks general questions with no stated need, budget, or timeline\",\n      \"Interested: the sender describes a real need but no budget or timeline\",\n      \"Qualified: the sender has a stated need plus either budget or a timeline\",\n      \"Hot: the sender has a stated need, approved budget, a decision timeline, and asks for next steps\"\n    ]\n  },\n  \"email_kind\": {\n    \"type\": \"choice\",\n    \"instructions\": \"What kind of email is `email`?\",\n    \"criteria\": {\n      \"purchase_inquiry\": \"A prospective customer asking about buying, pricing, or a demo\",\n      \"support_request\": \"An existing customer asking for help with the product\",\n      \"partnership_pitch\": \"Another company proposing a partnership or reselling deal\",\n      \"vendor_solicitation\": \"Someone trying to sell us their own product or service\",\n      \"spam\": \"Unsolicited bulk or irrelevant mail\"\n    }\n  },\n  \"needs_personal_reply\": {\n    \"type\": \"noul\",\n    \"instructions\": \"Does `email` ask for something that only a person on our team can answer, such as custom pricing or scheduling a call?\",\n    \"criteria\": {\n      \"true\": \"The sender asks for custom pricing, a meeting, or another answer a template cannot give\",\n      \"false\": \"A standard automated reply or a link to existing material fully answers the email\"\n    }\n  }\n}\n```\n\nThe answers:\n\n| Question | Answer | How sure | \n|---|---|---|\n| Lead strength | **4 (Hot)** | confidence 1.0 | \n| Email kind | **purchase_inquiry** | confidence 1.0 | \n| Needs personal reply | **yes** | p = 0.97–0.98 | \n\nThe request used 758 input tokens, about **$0.00003**, and took 295–528 ms.\n\nTwo things stood out:\n\n**The questions don't depend on each other.** Each one is answered on its own, against the same state, and you pay for the state once. Putting several questions in one request is the cheap default.\n\n**Jev returns judgments. My code turns them into decisions.** \"Hot lead + needs a personal reply → answer today\" is a rule I wrote myself, not something I asked Jev.\n\nThat second point became the rule for everything after: **Jev decides, Claude writes.** Jev classifies, scores and estimates likelihood. Claude writes the replies, summaries and code. When Jev isn't sure, Claude makes the call and says so. I saved this as a reusable Claude Code skill (`jev-sort`), so \"use Jev to sort these\" now works on any pile of text.\n\n## The router idea\n\nOnce you have a classifier that answers in about 400 ms for a fraction of a cent, routing is the obvious next step:\n\nFor every message I send, ask Jev: *what's the smallest model that can do this job well?*\n\nSend the job to that model.\n\nThere are four sizes, matched to the four Claude models I have, smallest to biggest:\n\n| Size | Typical job | Helper | Model | Input $/MTok | \n|---|---|---|---|---|\n| tiny | a lookup, a rename, a one-line answer | `jev-tiny` | Claude Haiku 5.5 | $0.10 | \n| everyday | a normal email, post, or short document | `jev-everyday` | Claude Sonnet 5.5 | $2 | \n| large | a multi-step build, research, a full report | `jev-large` | Claude Opus 5.5 | $4 | \n| hardest | strategy, or anything where a wrong call is expensive | `jev-hardest` | Claude Fable 5.1 | $10 | \n\nThe Haiku-to-Fable gap is 100× on input price. If even a fraction of daily messages are tiny, that's real money.\n\n## Claude Code can't switch models per message\n\nClaude Code can't change the main session's model for each message. Whatever model the session runs on reads every prompt you send.\n\nWhat Claude Code *does* have is:\n\n**Hooks**: shell commands that run on lifecycle events. `UserPromptSubmit` fires before each message reaches the model. It can add context to the message, or block the message.\n\n- **Subagents** : agent definitions in`~/.claude/agents/*.md` . Each one can pin its own`model:` .\n\nSo the closest working design is: **the hook sizes the message, and the main model hands the job to a helper pinned to the right model.**\n\n```\n you type a message\n        │\n        ▼\n ┌──────────────────────────────┐\n │ UserPromptSubmit hook        │   router OFF? → exit, no output\n │ ~/.claude/jev-router/        │   \"jev router on/off/status\"? → answer, block\n │   router.py hook             │   secret-looking? short reply? → no output\n └──────────────┬───────────────┘\n                │ POST /v1/systemone  (1.5 s timeout)\n                ▼\n        ┌───────────────┐\n        │   Jev: size?  │  → tiny / everyday / large / hardest + probability\n        └───────┬───────┘\n                │ p ≥ 0.60 → additionalContext: \"delegate to jev-<size>\"\n                │ p < 0.60 or error → no output (main model handles it)\n                ▼\n ┌──────────────────────────────┐\n │ main session (e.g. Opus)     │──► Agent(subagent_type=\"jev-tiny\")\n └──────────────────────────────┘          │  runs on Haiku 5.5\n                ▲                           │\n                └──── result + \"— done by Claude Haiku 5.5 (jev-tiny)\"\n```\n\nThe trade-off: the main model still reads your message and passes back the helper's answer, so you pay for that small amount of reading and relaying. You save the cost of the actual work, which is usually the expensive part: long drafts, multi-step builds and research.\n\n## The question\n\nOne question, one `choice`:\n\n```\nQUESTION = {\n    \"size\": {\n        \"type\": \"choice\",\n        \"instructions\": \"What is the smallest AI model size that can do the job in `message` well?\",\n        \"criteria\": {\n            \"tiny\": \"A lookup, a rename, or a one-line answer\",\n            \"everyday\": \"A normal email, post, or short document\",\n            \"large\": \"A multi-step build, research, or a full report\",\n            \"hardest\": \"Strategy, or a job where a wrong call is expensive\",\n        },\n    }\n}\n```\n\nThree design choices, all taken from TypeSafe's integration guidance:\n\n**Descriptive option names.** Jev sees option names (`tiny`, `everyday`, …) but never question IDs. A name like `option_1` gives the model nothing to go on.\n\n- **Each option describes a situation.** \"A normal email, post, or short document\" is something you can point at. \"Medium difficulty\" isn't.\n- **Gate on the top option's probability, not `confidence`.**` confidence` measures how concentrated the distribution is, which isn't the same as how likely the answer is to be right. The router acts only when the top pick's probability is**≥ 0.60** .\n\n## When the router stays out of the way\n\nSeveral cases never get routed. They all end the same way: the hook prints nothing, and the main model handles the message as if the router didn't exist.\n\n``` python\ndef decide(prompt, has_history):\n    if SECRETS.search(prompt):\n        return \"skipped_private\", {}       # never sent to Jev\n    if is_followup(prompt, has_history):\n        return \"self_followup\", {}         # needs the conversation; a helper wouldn't have it\n    try:\n        size, p, conf, tokens, ms = ask_jev(prompt)\n    except Exception as e:                 # slow, offline, bad key, schema change\n        return \"self_error\", {\"error\": f\"{type(e).__name__}: {e}\"[:200]}\n    info = {\"size\": size, \"p\": p, \"confidence\": conf, \"tokens\": tokens, \"ms\": ms}\n    return (size if p >= MIN_SURE and size in SIZES else \"self_unsure\"), info\n```\n\n**Short replies.** \"yes do that but make it shorter\" only makes sense inside the conversation. A helper starts with a blank context, so handing it that message would be useless. The router catches these in code, without asking Jev: the conversation must already have history, and the message must be at most 12 words and either start with a reply word (*yes, ok, sure, go ahead…*) or contain a word that points back (*that, it, again, shorter…*). Doing this in code keeps the Jev\n\ncall to one question, and those messages never leave the machine.\n\n**Secrets.** A regex looks for API-key shapes, `password:`, private-key headers and GitHub tokens. A match means the message is never sent to Jev. With the router on, everything else goes to TypeSafe's API, so this is the minimum safety net.\n\n**Failures.** The Jev call has a hard 1.5 s timeout, and the hook has a 3 s ceiling in`settings.json`. The whole hook is wrapped so it always exits 0:\n\n```\nif arg == \"hook\":\n    try:\n        hook()\n    except Exception:\n        pass  # never block a message\n    sys.exit(0)\n```\n\nThe router's own requirement was **never slow me down or block a message**. A router that loses a message when an API is having a bad day costs more than it saves.\n\n## What the hook outputs\n\nWhen Jev is sure, the hook returns two things. A `systemMessage` shows you a one-line status in the UI. An `additionalContext` tells the main model what to do:\n\n```\n{\n  \"systemMessage\": \"Jev → tiny (100%) → jev-tiny\",\n  \"hookSpecificOutput\": {\n    \"hookEventName\": \"UserPromptSubmit\",\n    \"additionalContext\": \"[jev-router] Jev sized this message as TINY (p=1.00). Delegate the job to the `jev-tiny` subagent ... Relay its result to the user as-is, keeping its final 'Done by' line. ... If the subagent is unavailable or fails, do the job yourself and say the router fell back.\"\n  }\n}\n```\n\nThe on/off switch uses the same hook. If you type exactly `jev router on`, `off` or `status`, the hook answers with `\"decision\": \"block\"` and the text as the `reason`. Your message never reaches the model, so toggling costs zero tokens:\n\n```\ncmd = prompt.lower().rstrip(\".!\")\nif cmd in (\"jev router on\", \"jev router off\", \"jev router status\"):\n    text = status_text(read_state()) if cmd.endswith(\"status\") else set_enabled(cmd.endswith(\"on\"))\n    print(json.dumps({\"decision\": \"block\", \"reason\": text}))\n    return\n```\n\nState lives in `~/.claude/jev-router/state.json`: on/off, counts per route, Jev calls and tokens. Writes take a file lock, so parallel sessions don't overwrite each other. A decision log records each route, its probability and its latency, never the message text.\n\n## The helpers\n\nEach helper is a short agent definition. The model is pinned in the frontmatter, and every reply ends with a line saying which model did the work:\n\n```\n---\nname: jev-tiny\ndescription: Jev router helper for tiny jobs (a lookup, a rename, a one-line answer). Use only when a [jev-router] note in the conversation says to delegate to jev-tiny.\nmodel: haiku\n---\n\nYou are the tiny-size helper in a model router. The main assistant handed you one job from the user.\n\n- Do the whole job well and return the finished result, ready to show the user.\n- Your prompt is all the context you have. If something essential is missing, make the most reasonable assumption and state it in one line.\n- Match the user's requested format and length.\n\nEnd your reply with exactly this final line and nothing after it:\n— done by Claude Haiku 5.5 (jev-tiny)\n```\n\nThe phrase \"Use only when a [jev-router] note…\" in the description matters. Claude Code picks subagents partly from their descriptions, and the helpers shouldn't be used unless the router asked.\n\nThe \"done by\" line is there for honesty. You see where the work came from, and if the main model did it instead of a helper, the line is missing.\n\n## Testing it for real\n\nI ran the real hook entry point, the same command `settings.json` calls, over ten messages: eight standalone jobs from tiny to hardest, and two short replies. To keep the real router off with clean stats, the test pointed it at a separate state folder.\n\n| Message | Jev's pick | p | Jev latency | \n|---|---|---|---|\n| What's the capital of Australia? | tiny | 1.00 | 523 ms | \n| Rename `usr_cnt` →`user_count` | tiny | 1.00 | 424 ms | \n| Short email to landlord about a leaking tap | everyday | 0.96 | 432 ms | \n| LinkedIn post: we shipped dark mode | everyday | 0.99 | 442 ms | \n| Python CLI: Google Calendar → SQLite, with tests and README | large | 0.99 | 344 ms | \n| Comparison report of 5 vector databases | large | 1.00 | 294 ms | \n| Move 40 services from AWS to Azure next quarter? | hardest | 0.91 | 320 ms | \n| 6 months of runway: cut prices, bridge round, or pivot? | hardest | 0.90 | 397 ms | \n| \"yes do that but make it shorter\" | *main model (short reply)* | — | not sent | \n| \"ok now do the same for the second one\" | *main model (short reply)* | — | not sent | \n\nResults:\n\n- **All 8 standalone jobs** landed in the expected size, all above 0.90.\n- **Total Jev cost:** 8 calls, 3,280 input tokens,**$0.000138** .\n- **Added latency:** about 0.3–0.6 s for routed messages, and about 60 ms when the hook exits early.\n\nThe failure paths matter just as much:\n\n| Scenario | Result | \n|---|---|\n| Invalid API key | exit 0, no output, ~1.3 s total | \n| No API key | exit 0, no output | \n| Garbage on stdin | exit 0, no output | \n| Message containing `api_key=` | exit 0, not sent, counted as `skipped_private` | \n| Router off | exit 0, no output | \n\nEach of these means the message goes through untouched.\n\n## Making it portable\n\nTo install the router on other Macs, it became a small package, [published to GitHub](https://github.com/TechPreacher/jev_router?ref=corti.com):\n\n```\njev-router/\n├── install.sh\n├── uninstall.sh\n├── README.md\n└── files/\n    ├── router.py\n    ├── settings_hook.py      # safe add/remove of the hook in settings.json\n    ├── README.md\n    └── agents/jev-{tiny,everyday,large,hardest}.md\n```\n\nThe hardest part was editing `~/.claude/settings.json` without breaking anyone's existing setup. Mine already has hooks from several other tools in it. `settings_hook.py`:\n\n- refuses to touch a file that isn't valid JSON (`install.sh` checks this first, so nothing gets copied either);\n- writes a timestamped backup before every change;\n- removes any existing router entry before adding one, so re-running install never adds a duplicate;\n- leaves every other key and hook alone.\n\nI tested this on a copy of my real settings file. After two installs there was exactly one router hook and everything else was unchanged. After uninstall, the parsed settings were identical to the original.\n\nThe hook command uses `$HOME` rather than a hard-coded path, so it works for any username:\n\n```\n{ \"type\": \"command\", \"command\": \"python3 -I \\\"$HOME/.claude/jev-router/router.py\\\" hook\", \"timeout\": 3 }\n```\n\nThe installer leaves the router **off** and never copies the API key. Each Mac sets its own `TYPESAFE_API_KEY`, and the installer warns you if it's missing.\n\n## What this doesn't solve\n\nSome limits worth being upfront about:\n\n- **The main model is still in the loop.** It reads every message and relays every helper reply. The router saves the cost of the work, not the cost of the conversation.\n- **Helpers start cold.** They get only the prompt the main model writes for them. That's the reason short replies stay with the main model, and the main model has to pack enough context into each handoff.\n- **Short-reply detection is a heuristic.** A short message like \"rename this file\" contains \"this\", so it stays with the main model instead of going to Haiku. That's safe, but it misses a saving.\n- **Your messages leave your machine.** With the router on, every message that isn't caught by the guards goes to api.typesafe.ai. The secret regex is a safety net, not a privacy guarantee.\n- **New helpers need a fresh session.** Claude Code loads agent definitions at startup, so a same-session handoff test fails with \"agent type not found\". After a restart, the four helpers show up as normal agents.\n\n## Takeaways\n\n- **Separate judgment from decision.** Jev answers \"how big is this job?\" Code turns that into \"send it to Haiku\". Thresholds, fallbacks and short-reply checks all live in code you can read and test.\n- **Calibrated probabilities make fallbacks easy.** \"Under 60% → the main model handles it\" is one line of code.\n- **Fail open, always.** Any tool that runs on every message has to fail without blocking anything.\n- **Say what the platform can't do.** Claude Code can't switch models per message, so the router says so and does the closest thing that works.\n\nThe code is a few hundred lines of Python with no dependencies, four short Markdown agent files, and one hook entry. A Jev call costs less than a hundredth of a cent, and on a good day the router keeps most small jobs off the expensive model.\n\nTo try it: install the package, start a new session, and type `jev router on`.", "url": "https://wpnews.pro/news/jev-decides-claude-writes-building-a-model-router-for-claude-code", "canonical_source": "https://corti.com/jev-decides-claude-writes-building-a-model-router-for-claude-code/", "published_at": "2026-10-27 18:40:12+00:00", "updated_at": "2026-10-27 18:42:13.473454+00:00", "lang": "en", "topics": ["ai-tools", "large-language-models", "ai-agents", "developer-tools"], "entities": ["Claude Code", "TypeSafe", "Jev", "jev-1.14.0", "Anthropic", "Claude"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/jev-decides-claude-writes-building-a-model-router-for-claude-code", "markdown": "https://wpnews.pro/news/jev-decides-claude-writes-building-a-model-router-for-claude-code.md", "text": "https://wpnews.pro/news/jev-decides-claude-writes-building-a-model-router-for-claude-code.txt", "jsonld": "https://wpnews.pro/news/jev-decides-claude-writes-building-a-model-router-for-claude-code.jsonld"}}