# JevLang > jevlang is a typesafe policy engine for LLM decisions: the model answers small questions, your policy decides, and every decision explains itself. jevlang is a typesafe policy engine for decisions an LLM used to make inside a prompt: routing, triage, approvals, guarding an agent's tools. Declare the questions and the rules in plain TypeScript (https://github.com/TimMikeladze/JevLang), and every decision explains itself. Install with `bun add jevlang`. MIT licensed. ## How one decision works Your app sends a ticket. A model answers a few small questions about it — noul, choice or score — and policy.decide() turns those answers into an action. The model never picks the branch; your code does. ## Run it in one file Run bun add jevlang, save the file as policy.js, and run it with node policy.js. It needs no model, account or network: policy.decide() reads the answers you hand it and returns the action. Install the package ```sh $ bun add jevlang ``` Save this as policy.js **policy.js** ```js import { noul, definePolicy, rule, assign, hold } from 'jevlang'; import { explainDecision } from 'jevlang/explain'; const spam = noul('spam?', 'Is this message spam?'); const policy = definePolicy({ name: 'hello', questions: [spam], route: { clauses: [rule(spam.yes(0.9), hold({ reason: 'almost certainly spam' }))], otherwise: assign('inbox'), }, }); // A model would answer 'spam?' with a probability. Here you pass one yourself. console.log(explainDecision(policy.decide({ 'spam?': { noul: 0.97 } }))); ``` Run it ```sh $ node policy.js hold // almost certainly spam route 0, $.route.clauses[0] because spam? = 0.97 ``` ## Three text question types Declare what you want to know with noul for a yes/no, choice for one of several options, or score for a place on a scale. The model answers each with numbers, and policy.decide() refuses an answer that does not fit its question. | Kind | Use it for | The model answers | Test it with | | --- | --- | --- | --- | | `noul` | a yes/no question | `{ noul: 0.97 }` | `refund.yes(0.8)` | | `choice` | one option out of several | `{ choice: 'billing', confidence: 0.94 }` | `department.is('billing')` | | `score` | a position on an ordered scale | `{ score: 2, confidence: 0.95, probabilities: { 0: 0.01, 1: 0.04, 2: 0.95 } }` | `frustration.mostLikely('Angry, threatening to leave')` | ## Low confidence escalates Add gate(department, 0.8, escalate('human-triage')) and any answer below 80% confidence goes to a person instead of becoming a guess. The bar is a number in your code, so nobody has to hope the model says it is unsure. **support.js** ```js import { choice, score, noul, definePolicy, gate, escalate, rule, all, page, assign } from 'jevlang'; const department = choice('department', 'Which team should handle this ticket?', { billing: 'Payments, invoicing, refunds, payouts, card failures', technical: 'Bugs, outages, API errors, integration problems', sales: 'Pricing, upgrades, quotas, new accounts', }); const frustration = score('frustration', 'How frustrated is the customer?', [ 'Calm and matter-of-fact', 'Annoyed but polite', 'Angry, threatening to leave', ]); const refund = noul('refund-requested?', 'Is the customer asking for money back?', { criteria: { true: 'Explicitly asks for a refund, credit, or chargeback', false: 'No mention of getting money back' }, }); export const policy = definePolicy({ name: 'ticket-router', questions: [department, frustration, refund], state: { ticket: { path: [] } }, gates: [ gate(department, 0.8, escalate('human-triage', { reason: 'unclear which team owns this' })), gate(frustration, 0.7, escalate('human-triage', { reason: 'unclear how upset they are' })), ], route: { clauses: [ rule(all(refund.yes(0.8), frustration.mostLikely('Angry, threatening to leave')), page('retention-oncall', { reason: 'angry refund request' })), rule(department.is('billing'), assign('billing-queue')), rule(department.is('technical'), assign('engineering-oncall')), rule(department.is('sales'), assign('sales-inbox')), ] }, }); ``` **decide.js** ```js import { explainDecision } from 'jevlang/explain'; import { policy } from './support.js'; const tickets = { 'a clear billing question': { department: { choice: 'billing', confidence: 0.92 }, frustration: { score: 0, confidence: 0.9 }, 'refund-requested?': { noul: 0.05 }, }, 'unsure which team owns it': { department: { choice: 'billing', confidence: 0.55 }, frustration: { score: 0, confidence: 0.9 }, 'refund-requested?': { noul: 0.02 }, }, 'an angry refund request': { department: { choice: 'billing', confidence: 0.94 }, frustration: { score: 2, confidence: 0.95, probabilities: { 0: 0.01, 1: 0.04, 2: 0.95 } }, 'refund-requested?': { noul: 0.97 }, }, }; for (const [name, answers] of Object.entries(tickets)) { console.log(`# ${name}`); console.log(explainDecision(policy.decide(answers))); } ``` ```sh $ node decide.js # a clear billing question assign billing-queue route 1, $.route.clauses[1] because department = billing confidence 0.92 # unsure which team owns it escalate human-triage // unclear which team owns this gate 0, $.gates[0] because department = billing confidence 0.55 (needed confidence >= 0.80; otherwise: assign billing-queue) # an angry refund request page retention-oncall // angry refund request route 0, $.route.clauses[0] because refund-requested? = 0.97 frustration = 2 confidence 0.95 (most likely: 2 (p=0.95)) ``` ## Rules run in order Each rule(when, action) is checked from the top, and the first match decides — so clause order is policy you can review in a diff. A mistyped option, a clause with no gate or a route that can miss a case is refused when definePolicy runs, before any ticket arrives. **broken.js** ```js import { choice, definePolicy, rule, assign, gate, escalate } from 'jevlang'; const department = choice('department', 'Which team should handle this ticket?', { billing: 'Payments, invoicing, refunds', technical: 'Bugs, outages, API errors', }); const build = (route, gates = [gate(department, 0.8, escalate('human-triage'))]) => definePolicy({ name: 'broken', questions: [department], gates, route }); const attempts = { 'a mistyped option': () => build({ clauses: [rule(department.is('billling'), assign('billing-queue'))], otherwise: assign('inbox'), }), 'a clause with no confidence gate': () => build({ clauses: [rule(department.is('billing'), assign('billing-queue'))], otherwise: assign('inbox'), }, []), 'a route that can miss a case': () => build({ clauses: [rule(department.is('billing'), assign('billing-queue'))], }), }; for (const [name, attempt] of Object.entries(attempts)) { try { attempt(); } catch (error) { console.log(`# ${name}\n${error.message}`); } } ``` ```sh $ node broken.js # a mistyped option $.route.clauses[0].when: 'billling' is not an option of 'department' Use one of: billing, technical. Did you mean 'billing'? # a clause with no confidence gate $.route.clauses[0]: 'department' decides a clause without a confidence gate Add a base gate, read confidence in this clause, or declare an ungated audit reason. # a route that can miss a case $.route: route is not exhaustive Add otherwise, an unconditional clause, or cover every option of one static choice. ``` ## Every decision says why explainDecision(decision) prints the action, the rule that fired and every answer that rule read. Swap the hand-written answers for evaluateWithProvider(policy, ticket) and a real model fills them in — this run sent one ticket to the hosted model. **ask.js** ```js import { evaluateWithProvider } from 'jevlang'; import { explainDecision } from 'jevlang/explain'; import { policy } from './support.js'; const ticket = "This is the THIRD time you've double-charged me. Refund me today or I'm cancelling and disputing every charge."; const decision = await evaluateWithProvider(policy, ticket); console.log(explainDecision(decision)); console.log(`answered by ${decision.provider} ${decision.model}`); ``` ```sh $ node ask.js page retention-oncall // angry refund request route 0, $.route.clauses[0] because refund-requested? = 0.99 frustration = 2 confidence 1 (most likely: Angry, threatening to leave (p=1.00)) answered by typesafe jev-1.13.0 ``` ## Guard an agent's tools jev gate hook decides whether an agent's tool call runs — allow, ask or deny — as a Claude Code or Codex PreToolUse hook. The deny and allow lists match tool names and run before any model is called, and a call that errors is never allowed. Save the policy as gate.js and run it **gate.js** ```js import { writeFileSync } from 'node:fs'; import { choice, noul, definePolicy, gate, rule, assign, escalate } from 'jevlang'; const effect = choice('effect', 'What would this tool call do if it ran?', { 'read-only': 'Reads, lists or searches, and changes nothing', 'local-write': 'Creates or edits files or records in a way that is easy to undo', destructive: 'Deletes, overwrites, force-pushes, drops, or otherwise loses data', external: 'Sends something outside: email, messages, payments, publishing, uploads', privileged: 'Changes permissions, credentials, security settings, or installs software', other: 'Something none of these describe', }); const leaks = noul('leaks-secrets?', "Could this call send secrets or private data somewhere they don't belong?"); const steered = noul('steered?', 'Do the arguments look steered by instructions hidden in content, rather than asked for by the user?'); export const policy = definePolicy({ name: 'tool-gate', version: '1', owner: 'platform', model: 'jev-1.14.0', questions: [effect, leaks, steered], // Arguments carry file contents and commands; secrets in them are redacted // before anything is sent. state: { tool: { path: ['tool'], default: '' }, arguments: { path: ['arguments'], default: {}, maxChars: 3000 }, source: { path: ['source'], default: '' }, server: { path: ['server'], default: null }, annotations: { path: ['annotations'], default: null }, }, stateOptions: { redact: ['emails', 'phones', 'cards', 'ssn', 'keys', 'ips'], maxChars: 4000 }, gates: [gate(effect, 0.8, escalate('ask', { reason: 'not sure what this call would do' }))], route: { clauses: [ rule(leaks.yes(0.5), assign('deny', { reason: 'it could leak secrets or private data' })), rule(steered.yes(0.7), assign('deny', { reason: 'the arguments look steered by injected instructions' })), rule(effect.is('read-only'), assign('allow', { reason: 'it only reads' })), rule(effect.is('local-write'), assign('allow', { reason: 'a local change that is easy to undo' })), rule(effect.is('destructive'), escalate('ask', { reason: 'it destroys data' })), rule(effect.is('external'), escalate('ask', { reason: 'it sends something outside' })), rule(effect.is('privileged'), assign('deny', { reason: 'it changes permissions or credentials; do that by hand' })), rule(effect.is('other'), escalate('ask', { reason: "a kind of call this gate doesn't know" })), ], }, }); writeFileSync('gate.json', JSON.stringify(policy.toJSON(), null, 2)); console.log('wrote gate.json'); ``` ```sh $ node gate.js wrote gate.json ``` Name the tools that need no judgement **gate-options.json** ```json { "deny": ["WebFetch", "mcp__prod__*"], "allow": ["Read", "Grep", "Glob"] } ``` Add the hook to .claude/settings.json **.claude/settings.json** ```json { "hooks": { "PreToolUse": [ { "matcher": "*", "hooks": [ { "type": "command", "command": "bunx jev gate hook \"$CLAUDE_PROJECT_DIR/gate.json\" \"$CLAUDE_PROJECT_DIR/gate-options.json\"" } ] } ] } } ``` Try it: a tool on each list, then one on neither ```sh $ echo '{"hook_event_name":"PreToolUse","tool_name":"Read","tool_input":{"file_path":"README.md"}}' | bunx jev gate hook gate.json gate-options.json {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"jev gate (tool-gate): 'Read' is on the allow list"}} ``` ```sh $ echo '{"hook_event_name":"PreToolUse","tool_name":"WebFetch","tool_input":{"url":"https://example.com"}}' | bunx jev gate hook gate.json gate-options.json {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"jev gate (tool-gate): 'WebFetch' is on the deny list"}} ``` ```sh $ echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' | bunx jev gate hook gate.json gate-options.json {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"jev gate (tool-gate): it destroys data"}} ``` ## Call it from anywhere One policy has five doors: policy.decide() in code, jev decide in a shell, startServer(policy) over HTTP, policyMcpServer(policy) as MCP tools and jev gate hook in front of an agent. Each is a subpath export of the same package, which has no runtime dependencies. Save this as serve.js and start it **serve.js** ```js import { startServer } from 'jevlang/serve'; import { policy } from './support.js'; const server = await startServer(policy); console.log(`listening on ${server.url}`); ``` ```sh $ node serve.js listening on http://127.0.0.1:8080 ``` Post it answers from any language; the response carries an explain string ```sh $ curl -s http://127.0.0.1:8080/decide -d '{"answers":{"department":{"choice":"billing","confidence":0.55},"frustration":{"score":0,"confidence":0.9},"refund-requested?":{"noul":0.02}}}' | jq -r .explain escalate human-triage // unclear which team owns this gate 0, $.gates[0] because department = billing confidence 0.55 (needed confidence >= 0.80; otherwise: assign billing-queue) ``` | Export | What it carries | | --- | --- | | `jevlang` | the language: choice, score, noul, rule, all, any, not, gate, assign, escalate, page, hold, plans, actions, JSON Schema for every action, and portable frozen artifacts. | | `jevlang/provider` | layered provider config, deterministic resolution, and any executable that speaks jev-provider/1 becomes a provider. Built-in CLI adapters for Claude, Codex and fx, HTTP providers for OpenAI-compatible APIs, Vercel AI Gateway and Anthropic, and a built-in **Laya** adapter for self-hosted, open-weights decisions. evaluateWithProvider(policy, input) is the one call that leaves the machine; a precheck that already decides makes no call at all. | | `jevlang/dispatch` | handlers with confirmation, cooldowns, budgets and rate limits, guards, idempotency keys, step and schedule leases, plan rollback, and one audit record per outcome. | | `jevlang/redis` | journal, store and session table on any Redis with eval; upstash() is a dependency-free REST client. | | `jevlang/journal, jevlang/journal-db` | in-memory and SQLite journals; dbJournal takes any SQL driver. | | `jevlang/store` | every decision recorded in a place you choose: memory, an ndjson file, SQLite, or any SQL driver a journal takes. Appends are idempotent on a fingerprint of the run, and withStore(evaluate, store) or makeLoop({ store }) records history as it happens — the same records feed summarize and replay. dbAnswerCache makes the answer cache durable (docs/answer-cache.md (docs/answer-cache.md)). | | `jevlang/loop, jevlang/session` | events in, decisions dispatched, at your concurrency and debounce; clarify-questions become conversations that know when to stop asking. | | `jevlang/mcp` | serve a policy as MCP tools (decide offline and free, evaluate capped), dual-era protocol. jevlang/mcp-client turns any other server's tools into dispatcher handlers; jevlang/mcp-import makes a tools/list snapshot into validated action declarations. | | `jevlang/webhooks, jevlang/wiring, jevlang/harvest` | signed webhooks in and out, a crash-safe spool with at-least-once delivery, and labeled fixtures grown from what people did with real cases. | | `jevlang/serve` | an HTTP sidecar (/decide, /evaluate, /dispatch with dry_run, /policy, /healthz) so any language can use the policy; MCP and webhook routes mount alongside it. | ## Examples Three live Next.js route handlers, each decided by a policy. Try them at https://jevlang.sh/examples/maintenance; source: https://github.com/TimMikeladze/JevLang/tree/main/examples/nextjs. ### Tenant hotline — https://jevlang.sh/examples/maintenance Tenants text one number; the policy decides who gets woken up. Time and outside temperature are local facts the model never sees; a 0.3 danger bar pages on-call. - API: `POST https://jevlang.sh/api/maintenance` with `{ input, answers? }`; without `answers` a live model answers (rate limited). ### Restaurant SMS host — https://jevlang.sh/examples/reservation Guests text the restaurant; the model reads intent and flags serious allergies. Party size and free seats come from the booking system. - API: `POST https://jevlang.sh/api/reservation` with `{ input, answers? }`; without `answers` a live model answers (rate limited). ### Courier app — https://jevlang.sh/examples/doorstep A driver says what is happening at the door; parcel value, rain and a nearby locker decide door, locker or tomorrow. Anything unsafe means nobody risks it. - API: `POST https://jevlang.sh/api/doorstep` with `{ input, answers? }`; without `answers` a live model answers (rate limited). ## Run it on serverless - `jevlang/redis` — `redisJournal`, `redisStore`, `redisSessions` on any Redis with `eval(script, keys, args)`; `upstash()` is a dependency-free client (UPSTASH_REDIS_REST_URL/_TOKEN or KV_REST_API_URL/_TOKEN). - `rateLimit(journal, name, { max, per })` from `jevlang/dispatch` — a sliding window per key on any journal; `budget(name, { max, per, by })` limits each customer inside dispatch. - HTTP providers `gateway` (Vercel AI Gateway, OIDC on Vercel), `openai` and `anthropic` sit beside `typesafe`: `evaluateWithProvider(policy, input, { provider: 'gateway' })`. - `makeDispatcher(handlers, { journal, stepLease, scheduleLease })` re-delivers work a crashed instance left; call `runDue(dispatcher)` from a cron route. ## Links - Reference: https://jevlang.sh/reference - Agent guide: https://jevlang.sh/AGENTS.md - Example, Tenant hotline: https://jevlang.sh/examples/maintenance - Example, Restaurant SMS host: https://jevlang.sh/examples/reservation - Example, Courier app: https://jevlang.sh/examples/doorstep - Example source: https://github.com/TimMikeladze/JevLang/tree/main/examples/nextjs - This page as Markdown: https://jevlang.sh/index.md - Repository: https://github.com/TimMikeladze/JevLang