Skip to content

TypeSafe Classifier ​

Jev is TypeSafe’s System One classifier. You call it from your own code, a hook, or an interrupt when the agent loop needs a typed decision: which team owns this ticket, whether a tool may run, whether the work can wait.

It answers Noul (yes/no), Choice (one label), and Score (a number on a rubric) questions about a state you pass in. It does not generate chat text, call tools, or stream tokens. The agent still uses Gemini, OpenAI, or another chat model. Jev does not sit in Agent(model=...).

Classifier constructors and classify / aclassify are on TypeSafe Classifier in Core.

Default model is Amazon Bedrock

Agent() with no model= uses Amazon Bedrock (Claude Sonnet 4.6 in us-west-2). Configure AWS credentials (aws configure or AWS_* env vars), or pass an explicit model — see Installation and Model Providers.

Install ​

Install elsai-model[typesafe], set TYPESAFE_API_KEY, and import TypeSafeClassifier. AgentKit does not ship a Jev plugin. That is the integration.

bash
pip install --extra-index-url https://core-packages.elsai.ai/root/elsai-model/ "elsai-model[typesafe]==2.1.3"
export TYPESAFE_API_KEY=sk-...
python
from elsai import Agent
from elsai_model.gemini import GeminiModel
from elsai_model.typesafe import Choice, Noul, Score, TypeSafeClassifier

agent = Agent(model=GeminiModel(...), tools=[...])  # the LLM
jev = TypeSafeClassifier(model_id="jev-latest")     # the decision layer

Question types ​

Use Noul when your code needs an if. Use Choice when options map to different handlers. Use Score when you compare a number to a threshold. Ask several questions in one call; they share the same state. See TypeSafe Classifier for the objects and fields.

User wantsAsk JevThen
Which agent / team?ChoiceStart that agent or enqueue
Skip the LLM?Score or NoulReturn a static reply or run the agent
May this tool run?Noulcancel_tool or interrupt
Which graph node?ChoiceRun that node
Act automatically?Noul + confidenceCode acts, or fall through to the LLM

Bring Jev into the agent loop ​

This is a working example of how you bring Jev into an agent’s loop.

Before the turn starts, a BeforeInvocationEvent hook asks Jev which team owns the ticket and stores that label on invocation_state. When the chat model proposes send_email, a BeforeToolCallEvent hook asks Jev whether the send is risky. If the probability is high, the hook cancels the tool with a fixed sentence. Jev names the lane and gates the tool. It does not write the customer reply.

Build state from event.messages or event.agent.messages, or from a dict you control. Mutate event.invocation_state in place; do not assign a new dict.

This is not a replacement for Permissions on file_read, file_write, editor, shell, python_repl, or execute. Use Jev on app tools those plugins do not already own.

python
import os
from elsai import Agent, tool
from elsai.agent import AgentConfig
from elsai.hooks import BeforeInvocationEvent, BeforeToolCallEvent, HookProvider
from elsai.plugins import hook
from elsai_model import LLM, Provider
from elsai_model.typesafe import Choice, Noul, TypeSafeClassifier

jev = TypeSafeClassifier(model_id="jev-latest")


@tool
def send_email(to: str, subject: str, body: str) -> str:
    """Send an email to a customer."""
    return f"Queued email to {to}: {subject}"


def state_from_messages(messages):
    parts = []
    for message in messages or []:
        texts = [
            block["text"]
            for block in message.get("content") or []
            if isinstance(block, dict) and "text" in block
        ]
        if texts:
            parts.append({"role": message.get("role"), "text": "\n".join(texts)})
    return {"messages": parts}


class TicketPolicy(HookProvider):
    @hook
    def route(self, event: BeforeInvocationEvent) -> None:
        response = jev.classify(
            state=state_from_messages(event.messages),
            questions={
                "team": Choice(
                    instructions="Which team should handle this?",
                    criteria={
                        "billing": "Payments, invoices, refunds",
                        "technical": "Bugs, outages, integrations",
                        "sales": None,
                    },
                ),
            },
        )
        event.invocation_state["team"] = response.choices["team"].choice

    @hook
    def gate_email(self, event: BeforeToolCallEvent) -> None:
        if event.tool_use["name"] != "send_email":
            return

        response = jev.classify(
            state={
                "messages": state_from_messages(event.agent.messages),
                "tool": event.tool_use["name"],
                "input": event.tool_use.get("input"),
            },
            questions={
                "risky": Noul(
                    instructions="Is this send_email call risky or unauthorized given the last messages?",
                    criteria={
                        "true": "The send is unauthorized, off-policy, or would expose sensitive data",
                        "false": "The send is an expected follow-up",
                    },
                ),
            },
        )
        noul = response.nouls["risky"].noul
        if noul >= 0.5:
            event.cancel_tool = f"send_email blocked (noul={noul:.2f})"


model = LLM(
    provider=Provider.GEMINI,
    model=os.getenv("GEMINI_MODEL_NAME", "gemini-2.5-flash"),
    api_key=os.environ["GEMINI_API_KEY"],
)

invocation_state = {}
agent = Agent(
    model=model,
    tools=[send_email],
    system_prompt="You help with customer tickets. Use send_email only when the customer asked to be emailed.",
    config=AgentConfig(hooks=[TicketPolicy()]),
)
result = agent(
    "I was charged twice. Email me a refund confirmation.",
    invocation_state=invocation_state,
)
print(invocation_state["team"], result)

Your app reads invocation_state["team"] and starts the billing agent, the infra agent, or a human queue. The chat model proposed send_email; Jev only gates it.

Pause for a human ​

This is a second working example. Same send_email tool, same Noul, but the hook picks proceed / ask / stop instead of only cancel.

Low probability: the tool runs. High probability: cancel_tool with a template. A middle band (about 0.4–0.7) calls event.interrupt(...) so a reviewer decides. Jev does not write the approval UI. See Interrupts.

python
from elsai.hooks import BeforeToolCallEvent, HookProvider
from elsai.plugins import hook
from elsai_model.typesafe import Noul, TypeSafeClassifier

jev = TypeSafeClassifier(model_id="jev-latest")


class EmailReview(HookProvider):
    @hook
    def review(self, event: BeforeToolCallEvent) -> None:
        if event.tool_use["name"] != "send_email":
            return

        response = jev.classify(
            state={
                "messages": state_from_messages(event.agent.messages),
                "tool": event.tool_use["name"],
                "input": event.tool_use.get("input"),
            },
            questions={
                "risky": Noul(
                    instructions="Is this send_email call risky or unauthorized given the last messages?",
                    criteria={
                        "true": "The send is unauthorized, off-policy, or would expose sensitive data",
                        "false": "The send is an expected follow-up",
                    },
                ),
            },
        )
        noul = response.nouls["risky"].noul
        if noul < 0.4:
            return
        if noul >= 0.7:
            event.cancel_tool = f"send_email blocked (noul={noul:.2f})"
            return

        approval = event.interrupt(
            "send-email-review",
            reason={"noul": noul, "input": event.tool_use.get("input")},
        )
        if approval != "approve":
            event.cancel_tool = f"send_email blocked (noul={noul:.2f})"

Register EmailReview the same way as TicketPolicy: AgentConfig(hooks=[EmailReview()]).

Other uses ​

These follow the same pattern: classify a state, then branch in your code.

Skip the LLM ​

Ask a Score for urgency (can wait / this week / today). If the score is low and confidence is high, return a canned “we’ll get to this” and do not start the agent. Jev sorts the ticket. It does not write the support reply.

Act when you are sure ​

Ask a Noul or Choice and read confidence. High confidence: your code refunds, tags, or closes. Low confidence: send the same ticket to the LLM agent.

Ask several questions in one call ​

At the start of a ticket, ask billing (Noul), team (Choice), urgency (Score), and needs-a-human (Noul) together. They share one state. Your code then branches. You do not ask the LLM four classification questions first. The Core example shows that call.

Stop after a tool ​

After a tool result, ask a Noul: is this resolved enough to stop? If yes, do not spend another model turn. If no, the existing agent continues. Jev is a stop/go bit, not a summary.

Dispatch a graph node ​

Before a node runs, ask a Choice over research, writer, reviewer. Your orchestrator starts that node. Each node still has its own chat model. See Multi-Agent.

Keep policy in your repo ​

Ask several atomic Scores (impact, reversibility, authorization). Weight them in Python, then cancel, interrupt, or proceed. Jev returns the numbers. The rule lives in your code.

Do not ​

  • Do not pass Jev to LLM(...) or Agent(model=...).
  • Do not expect it to stream tokens, call tools, or write Guide(reason) prose. If you cancel a tool, use a template.
  • Do not flatten chat messages for them. Build state from event.agent.messages or a dict you control.
  • Do not stack Jev in front of file_read / file_write / editor / shell / python_repl / execute unless you want a second, semantic policy on top of the existing permission plugins.

Copyright © 2026 elsai foundry.