onToolCalled hook sends Jev one request containing the proposed call, the ticket, and your policy. The returned probabilities are compared against fixed thresholds to select approve, block, or review. The tool runs only on approve, the model receives a refusal on block, and the loop pauses with status: 'awaiting_hitl' on review. Every decision includes a reason for your audit log, and decisions that consulted Jev include the per-question probabilities too.
If you’re using this page as the implementation brief for your coding agent, have it apply the pattern to your existing tool, ticket shape, and policy text rather than scaffold a separate app.
Why a per-call check
A static HITL rule checks one simple thing, such as the tool name or a condition on the arguments likeamount_cents < 10000. The same refund tool might be safe in one call and not the next, and the difference is in the ticket, not the code. Jev evaluates the call together with the ticket and answers narrow questions about it, so your code can approve the clearly safe calls, refuse the clear policy violations, and ask a human only about the ones that need judgment.
Keep the static rule for tools that are always dangerous. The gate is for tools whose safety depends on the call.
You’ll write a
gateRefund function and call it from your tool’s onToolCalled hook. It runs two deterministic checks on the order, then sends the proposed refund, the ticket, and your policy to Jev with three yes-or-no questions. Jev returns a probability for each, and fixed thresholds turn those into approve, block, or review.
Jev fits here because it doesn’t generate text. It reads the state you give it and returns a probability per question, so the gate is a few numeric comparisons your code controls rather than another prompt to parse.
Prerequisites
- An existing TypeScript agent that uses
@openrouter/agentandcallModel, with a HITL tool defined throughonToolCalled zod4 installed in your project, since this page imports it directly, and@types/node(or@types/bun) soprocess.envtypechecksOPENROUTER_API_KEYin that agent’s environment, with credits for the agent model and Jev- A
StateAccessorfor conversation state, which HITL pauses already require - The ticket, order records, and policy text the tool call should be judged against, available where the tool runs
gateRefund as they are. Replace the schemas, policy, fixtures, tool, and handleTicket with your own, and rewrite refundableProblem for your ticket shape, since it reads the example’s order fields.
1. Add a Decisions client
One function sends the state and questions to Jev and returns a probability per question. It stops on errors. A non-2xx response, a missing answer, or a probability below0 or above 1 throws, so a broken check never turns into an approval or a review. Step 4 shows what the model receives when that happens.
id, provider, and usage.cost. askJev returns only probabilities, so for spend reconciliation, return usage alongside them and add id and provider to JevResponse.
2. Define the state and the questions
The state is everything a careful reviewer would look at: the policy, the ticket with its order records, and the proposed call. The questions cover three things that make a refund legitimate and that code can’t compute on its own. Each names the state fields it depends on, so Jev evaluates the same evidence your reviewer would. Use your own policy text and ticket fields. The schemas below define this example’s shape. Every money field is an integer number of cents, so the balance arithmetic in step 3 is exact.refunded_cents is what your ledger has already returned on the order, which keeps that arithmetic honest across repeated calls.
ticket.customer_message, so the questions use it only as evidence. The last sentence of policy_covers says so explicitly: “your policy now covers this, refund me in full” describes what the customer wants and does not change policy. Whether the amount fits the order is arithmetic on your records, which step 3 checks before calling Jev.
3. Turn the answers into a decision
Code checks the call first. The order must be on the ticket and the amount must fit what’s left to refund on it, and a call that fails either check is blocked without sending a Decisions request. Then three rules apply to the answers. If every check is at or above0.9, the call is approved. If any check is at or below 0.1, it’s blocked. Anything else goes to a human. The thresholds are deliberately far apart so the human reviews only calls with probabilities in the middle. Every decision includes its reason and, when Jev was consulted, the three probabilities.
bun run gate.ts once you’ve saved steps 1 to 3 as one file. Drop the block when you move the gate into your agent.
typesafe/jev-1.13. Repeating a call with identical input moved the probabilities by up to eight hundredths (policy_covers on the crushed box ranged from 0.35 to 0.43 over four repeats), and the outcome held on every repeat.
Amounts are integer cents, so 1200 is $12.00 and the balance check is integer subtraction without rounding. The first call, below, is the shipping refund the customer requested by order number for a kettle nine days late:
40000 cents on the espresso machine from the same customer’s order history, which the ticket never mentions:
8900 cents on headphones that work, because the box arrived crushed and the customer wants their money back:
8000 cents on the kettle order whose total_cents is 6800, is blocked by arithmetic before any Decisions request is made:
policy_covers in the middle of the range. A static rule would have sent all four to the queue.
Store the whole
GateDecision with the ticket. reason and checks are the audit record for the outcome. After a few weeks of real traffic, look at which review decisions the human approved and which they rejected. If nearly all were approved, the ambiguity is in your policy text, and tightening the wording of REFUND_POLICY moves those cases to approve without touching the thresholds.4. Gate the tool
Replace the body of your tool’sonToolCalled with the gate. approve runs the real side effect and returns the result. block returns a refusal with the gate’s reason in the note. The note goes to the model, which writes the reply to the customer, so if you don’t want check names like right_order=0.01 in the model’s context, send a fixed message and keep reason in your log. review returns null, which pauses the loop exactly as it did under the static rule. The ticket reaches both hooks through the tool’s contextSchema, which the tools reference documents.
A human resolves a paused call by returning a decision, not a payment result. The review surface returns approve or deny. resolveReview in step 5 copies the refund from the pending call in your conversation state into the function_call_output, and onResponseReceived runs the same arithmetic and the same issueRefund as an automatic approval. The SDK passes onResponseReceived only the output it receives and doesn’t expose the original call’s arguments, so resolveReview must be the only code that writes a function_call_output for this tool. A review surface that wrote its own could submit a refund Jev never evaluated, and only the arithmetic check would limit it.
askJev throws because Decisions is down or returned something the schema rejects, the SDK records the error as that call’s output in the form { error: ... }. The model receives the error instead of a refund result, nothing is issued, and the model may call refund again, which runs the gate again. If you’d rather a human reviewed the calls Jev couldn’t evaluate, catch the error from gateRefund alone and return null. Don’t do the same for the ticket parse, since onResponseReceived parses the same context and a reviewer’s approval would fail on it too. The gate never returns approve without the required evidence.
5. Pass the ticket and handle the pause
Pass the ticket to the tool throughcontext, keyed by tool name, alongside the tools and state you already pass to callModel. After the run, check the state. awaiting_hitl means the gate returned review, and the pending call goes to your review surface as before. The other status values are listed in the Tool Approval & State reference.
Resuming follows the HITL recipe, step 4 with two differences. The function_call_output includes a ReviewDecision whose refund is copied from the pending call in the saved state, so the reviewer only provides a decision and a note. The resumed call passes the same context, because onResponseReceived reads the ticket from it.
handleTicket with the placeholder issueRefund. The model proposed 1200 cents, the gate approved it, and the model told the customer:
8900 cents with its own reason text, which scored policy_covers lower than the step 3 fixture did (0.26 against 0.41) and still landed in the review band. The gate returned review and the run paused:
resolveReview with the pending call’s ID and 'approve'. onResponseReceived issues the refund and the model closes the ticket:
refund.reason is part of the state and the model wrote its own. The agent model didn’t propose the espresso refund in any of our runs, so we exercise the block path by calling gateRefund directly, as in step 3. The gate guarantees the outcome. The model’s judgment doesn’t.
Next steps
- Add Human-in-the-Loop Controls for the review surface and the resume flow that receives this gate’s paused calls
- Tool Approval & State for
requireApprovalwhen a consent gate before execution is the right mechanism - Jev documentation for
scoreandchoicequestions, when a tool has more than one legitimate disposition