
TypeSafe’s Jev answers typed questions with probabilities instead of generating text. That makes it useful in three specific places inside an agent, and the code for each is short. The examples run on Cloudflare Workers AI because that’s where we host most of what we build, but the shape transfers to any runtime; the model is also on Vercel’s AI Gateway and OpenRouter.
One rule holds throughout: Jev is a signal, not an oracle. It sits beside the agent and answers narrow questions fast. The agent, the code and, where it matters, a person still make the call.
A thin, typed wrapper
Start by hiding the model behind an interface. Not for elegance: so that the evaluation set, not the vendor, decides what runs here, and so a swap is a config change.
// lib/decide.ts
type Noul = { type: "noul"; instructions: string };
type Choice<O extends string> = {
type: "choice";
instructions: string;
criteria: Record<O, string>;
};
type Score = { type: "score"; instructions: string; criteria: string[] };
type Question = Noul | Choice<string> | Score;
type Answer<Q> = Q extends Noul
? { noul: number }
: Q extends Choice<infer O>
? { choice: O; confidence: number; probabilities: Record<O, number> }
: {
score: number;
confidence: number;
probabilities: Record<string, number>;
};
export type Answers<Qs extends Record<string, Question>> = {
[K in keyof Qs]: Answer<Qs[K]>;
};
export async function decide<Qs extends Record<string, Question>>(
ai: Ai,
state: unknown,
questions: Qs
): Promise<Answers<Qs>> {
const res = (await ai.run("typesafe/jev", { state, questions })) as {
answers: Answers<Qs>;
};
return res.answers;
} One call, every question answered in parallel, every answer already typed. There is no JSON to parse and nothing to retry for malformed output, because the model can only choose among what you wrote.
1. A safety gate in front of destructive tools
Every agent that can delete, send or pay needs a “should I really do this?” check between deciding and doing. Most harnesses either hard-code rules or ask the same frontier model a second time, which is slow, expensive and correlated with the first answer. A decision model is a better fit: fast, cheap, independent, and it returns a confidence you can route on.
// lib/guard.ts
import { decide } from "./decide";
export type Verdict = "allow" | "confirm" | "block";
export async function guardToolCall(
ai: Ai,
input: {
userRequest: string; // what the person actually asked for
toolName: string;
args: unknown;
recentSteps: string[]; // last few agent steps, summarised
}
): Promise<{ verdict: Verdict; why: Record<string, number> }> {
// Pre-filter the state: only what the decision depends on.
const state = {
request: input.userRequest,
action: { tool: input.toolName, args: input.args },
recent: input.recentSteps.slice(-5),
};
const a = await decide(ai, state, {
irreversible: {
type: "noul",
instructions:
"The action permanently deletes, sends, publishes or moves money and cannot be undone by the agent.",
},
matches_request: {
type: "noul",
instructions:
"The action is a direct, expected part of fulfilling the user's request, not an inference the agent made on its own.",
},
scope: {
type: "choice",
instructions: "How much does this action affect?",
criteria: {
single: "One record, message or file",
several: "A handful of items the user named",
bulk: "Many items, or items the user did not name",
},
},
});
// Combine in code. The weights and thresholds are ours to read and version.
const risky = a.irreversible.noul > 0.6 || a.scope.choice === "bulk";
const unclear = a.matches_request.noul < 0.7 || a.scope.confidence < 0.6;
const verdict: Verdict =
!risky && !unclear
? "allow"
: risky && a.matches_request.noul < 0.4
? "block"
: "confirm";
return {
verdict,
why: {
irreversible: a.irreversible.noul,
matches_request: a.matches_request.noul,
scope_confidence: a.scope.confidence,
},
};
} Wiring it into a harness is a wrapper around execute. With the AI SDK it looks like this:
import { tool } from "ai";
import { z } from "zod";
import { guardToolCall } from "./lib/guard";
const deleteCustomer = tool({
description: "Delete a customer and all of their data.",
inputSchema: z.object({ customerId: z.string() }),
execute: async (args, { experimental_context: ctx }) => {
const { verdict, why } = await guardToolCall(ctx.ai, {
userRequest: ctx.userRequest,
toolName: "deleteCustomer",
args,
recentSteps: ctx.steps,
});
if (verdict === "block") {
return { ok: false, reason: "Blocked by safety gate", why };
}
if (verdict === "confirm") {
// Surface to the human; the agent continues once they approve.
return { ok: false, needsConfirmation: true, why };
}
await ctx.db.customers.delete(args.customerId);
return { ok: true };
},
}); Two things to notice. The three questions are independent, so they cost one round trip. And the gate never decides on its own: it sorts actions into three buckets, and the thresholds that define the buckets live in your code, where a reviewer can read them and a test can pin them.
2. A router in front of the agent
Not every message deserves an agent. A large share of inbound traffic to any assistant is answerable from a knowledge base, is a simple status query, or should go straight to a person. Deciding that with the frontier model means paying frontier prices to discover you didn’t need it.
// routes/inbound.ts
import { decide } from "../lib/decide";
export async function route(
ai: Ai,
message: string,
account: { plan: string; openIncidents: number }
) {
const a = await decide(
ai,
{ message, account },
{
intent: {
type: "choice",
instructions: "What is the person trying to do?",
criteria: {
faq: "A general question answerable from documentation",
status:
"Asking about the state of their own account, order or ticket",
change: "Asking us to change, cancel or create something",
complaint: "Expressing dissatisfaction or asking for a human",
other: "None of the above",
},
},
needs_tools: {
type: "noul",
instructions:
"Answering properly requires looking up or changing account data.",
},
urgency: {
type: "score",
instructions: "How time-sensitive is this?",
criteria: [
"No deadline implied",
"Wants it today",
"Blocked or losing money now",
],
},
}
);
// Low confidence anywhere → don't guess, let the full agent handle it.
if (a.intent.confidence < 0.6) return { path: "agent" as const, a };
if (a.intent.choice === "complaint" || a.urgency.score >= 1.6) {
return { path: "human" as const, a };
}
if (a.intent.choice === "faq" && a.needs_tools.noul < 0.3) {
return { path: "rag" as const, a }; // retrieval + a small model
}
return { path: "agent" as const, a };
} The economics are simple. The router call costs a fraction of a cent and returns in well under a second. Every message it sends to the retrieval path instead of the agent saves a multi-second, multi-step frontier run. What fraction that is depends entirely on your traffic, which is exactly what an evaluation set will tell you before you commit.
3. Loop control: is the agent done, and is it stuck?
The third place is inside the loop itself. Agents fail in two boring ways: they stop too early with a partial answer, or they circle, re-running the same tool with small variations. Both are cheap to detect with a couple of questions after each step.
// lib/loop.ts
import { decide } from "./decide";
export async function shouldContinue(
ai: Ai,
task: string,
steps: { tool: string; args: unknown; result: string }[]
) {
const a = await decide(
ai,
{ task, steps: steps.slice(-8) },
{
complete: {
type: "noul",
instructions:
"The steps so far contain everything needed to fully answer the task as stated.",
},
progress: {
type: "score",
instructions: "Compared with the previous steps, the latest step…",
criteria: [
"Repeated an earlier step with no new information",
"Added a little",
"Clearly moved the task forward",
],
},
}
);
if (a.complete.noul > 0.85) return { action: "finish" as const, a };
if (a.progress.score < 0.4 && steps.length >= 4)
return { action: "escalate" as const, a };
return { action: "continue" as const, a };
} In a step-based harness (the AI SDK’s stopWhen, Mastra’s step hooks, or a hand-rolled loop) this runs between iterations. It replaces the usual “max 25 steps and hope” with a check that costs less than the tokens of one extra step, and it returns a reason you can log.
The part that decides whether any of this ships
None of these thresholds (0.6, 0.7, 0.85) are magic. They are placeholders until an evaluation set replaces them. The set is a few hundred real inputs from the product, labelled by a person, stored next to the code:
// eval/guard.test.ts
import cases from "./guard-cases.json"; // [{ input, expected: "allow" | "confirm" | "block" }]
const results = await Promise.all(
cases.map(async (c) => {
const { verdict, why } = await guardToolCall(ai, c.input);
return { expected: c.expected, verdict, why };
})
);
const accuracy =
results.filter((r) => r.verdict === r.expected).length / results.length;
const dangerousMisses = results.filter(
(r) => r.expected === "block" && r.verdict === "allow"
).length;
console.table({ accuracy, dangerousMisses, n: results.length }); Run it for the current rules, for Jev, and for any other candidate. The comparison decides. Two metrics matter more than accuracy for a gate: the count of dangerous misses (should have blocked, allowed), which must be zero or near it, and calibration, meaning that of the actions Jev gave a 0.9 to, roughly nine in ten really were what it said. The independent phishing benchmark in our Jev review measured an expected calibration error of 0.154, which is usable for routing and not good enough to bet money on unsupervised. Your data will differ; measure it.
Then ship it behind the thresholds, log every answer with its probabilities, and watch the confidence distribution in production. Drift shows up there first.
What this adds up to
An agent harness with a decision model beside it has three cheap, fast checkpoints it didn’t have before: one before the agent starts, one before anything irreversible happens, and one between steps. Each replaces either a brittle rule or an expensive second opinion from the same model that made the first decision. The agent is still the one doing the work. It just stops being the only judge of its own work, and the judgement becomes something you can measure, threshold and version.
That’s the version of “AI in production” we’re comfortable putting a client’s name on.
References
- Our earlier post: Jev, and the case for models that don’t talk
- Cloudflare, Workers AI model reference: typesafe/jev, source of the request and response shapes in the code
- TypeSafe, Quickstart
- Vercel, AI SDK tools