Create your own
Lesson illustration

Building a TypeScript Intent Router for Deterministic, LLM, and Human Review Dispatch

Welcome to Week 3. This module turns Jev’s typed judgments into application patterns that can live behind real product endpoints. We will begin with an intent router: a small service that interprets a user’s primary request and sends it to the appropriate kind of handler without letting a model directly perform sensitive actions.

By the end of this lesson, you will have a TypeScript router that uses a Jev Choice question to route support requests to one of three destinations:

  • Deterministic application code for a bounded, safe operation such as order lookup.
  • An LLM for constrained, non-sensitive explanatory text.
  • Human review for sensitive, ambiguous, unsupported, or low-confidence cases.

The router’s job: choose a handler, not solve the request

An intent router is a boundary component. Its input is an authenticated, validated application request; its output is a dispatch decision. Jev supplies the semantic judgment — “what is this person primarily trying to do?” — while TypeScript owns the operational policy.

That separation matters:

ResponsibilityOwnerExample
Authenticate the customerDeterministic codeValidate session and account ownership
Validate request shapeDeterministic codeEnsure customerId and orderId are valid
Identify primary intentJev“This is an order-status request”
Decide whether confidence is sufficientTypeScript policyRequire confidence of at least 0.78
Fetch an orderDeterministic codeQuery your order service
Produce a public help explanationLLM, within constraintsExplain how a documented feature works
Approve a refund or change account accessHuman reviewerInspect evidence and authorize action

The crucial rule is: Jev does not invoke tools, mutate data, send emails, or approve money movement. It provides a bounded judgment; your program decides what that judgment permits.

For this router, use a Choice question with options that correspond to meaningful handling paths. A workable initial taxonomy for a support application is:

  • order_status: the customer wants the current status or location of an existing order.
  • public_product_guidance: the customer asks how a public product feature works.
  • account_or_billing: the request involves login, account data, subscription, payment, charge, or refund.
  • other: the message does not fit safely, contains multiple material requests, or is too vague.

Notice that account_or_billing is not necessarily an “intent we automate.” It is a deliberately broad review category. The router can recognize it with high confidence and still dispatch it to a person because the risk lies in the action, not merely in classification uncertainty.

The “Anatomy of one Jev call” diagram shows Jev receiving a compact state, answering typed questions in one request, and returning both selected outcomes and uncertainty information. This lesson uses one Choice question, then makes the dispatch decision in TypeScript.

Jev TypeScript quickstart | Refix

Read the “Route with confidence in code” portion of Refix’s Jev TypeScript quickstart. It illustrates the central boundary used in this lesson: Jev classifies, while application code performs queue assignment and other side effects.

In the material immediately before and within the subsection titled “Route with confidence in code,” read the rationale. Then continue through the code example in that subsection. Focus on the separate confidence tests for department and urgency, and on why assignQueue and setPriority remain ordinary application functions.


Define a narrow routing contract

Before calling Jev, decide what the router is allowed to see and what every destination is allowed to do.

Assume an HTTP handler has already authenticated the caller and validated the request. The router receives only the fields it needs:

type SupportRequest = {
  customerId: string;
  orderId: string;
  message: string;
};

The classifier only needs message. The order lookup needs customerId and orderId, but those identifiers should come from validated application state — not be extracted by an LLM from free text.

This restriction has two benefits:

  1. The Jev state remains relevant to the intent decision.
  2. The deterministic handler can enforce authorization independently of the routing model.

For example, a customer may write “Where is order 4512?” The router can classify the message as order_status, but the order service must still verify that order 4512 belongs to the authenticated customer. Classification is never authorization.

Design the question around the next action

The wording below asks for the primary requested outcome, rather than attempting a vague topic label such as “What is this about?” Each option describes observable inclusion rules and, where necessary, exclusions.

import { choice, TypeSafeClient } from "@typesafe-ai/sdk";

const client = new TypeSafeClient();

async function classifyIntent(message: string) {
  const response = await client.systemOne({
    model: "jev-latest",
    state: { message },
    questions: {
      intent: choice(
        "What is the customer's primary requested outcome in `message`?",
        {
          order_status:
            "The customer asks where an existing order is, when it will arrive, or for its current delivery or fulfillment status.",
          public_product_guidance:
            "The customer asks how to use a publicly documented product feature. The request does not require account access, payment information, a subscription change, or an action on the customer's behalf.",
          account_or_billing:
            "The customer asks about login, account access, personal account data, subscriptions, charges, invoices, payments, refunds, or any account-specific change.",
          other:
            "The message does not fit the listed categories, is too ambiguous to route safely, or contains multiple material requests that need separate handling.",
        },
      ),
    },
  });

  return response.answers.intent;
}

The returned answer has three pieces worth preserving:

  • choice: the selected category.
  • probabilities: the full distribution across categories.
  • confidence: how concentrated or dispersed that distribution is.

A Choice can select order_status while still having moderate confidence because account_or_billing retained meaningful probability. In a production router, the selected category alone is not enough to justify automation.


Implement a three-destination TypeScript router

The following example keeps external systems behind injected dependencies. That makes the router easy to unit-test: Jev’s typed response can later be replaced by a recorded fixture, while the policy itself remains ordinary deterministic code.

import { choice, TypeSafeClient } from "@typesafe-ai/sdk";

const client = new TypeSafeClient();

const MIN_ROUTING_CONFIDENCE = 0.78;

type SupportRequest = {
  customerId: string;
  orderId: string;
  message: string;
};

type ReviewReason =
  | "low_intent_confidence"
  | "account_or_billing"
  | "unsupported_or_ambiguous_intent"
  | "jev_unavailable"
  | "llm_unavailable";

type IntentAnswer = {
  choice: string;
  confidence: number;
  probabilities: Record<string, number>;
};

type ReviewTask = {
  customerId: string;
  orderId: string;
  message: string;
  reason: ReviewReason;
  intent?: string;
  confidence?: number;
  probabilities?: Record<string, number>;
};

type RouterDependencies = {
  lookupOrder(input: {
    customerId: string;
    orderId: string;
  }): Promise<unknown>;

  answerFromApprovedDocs(input: {
    message: string;
  }): Promise<string>;

  createReviewTask(input: ReviewTask): Promise<{
    id: string;
  }>;

  log(event: string, fields: Record<string, unknown>): void;
};

type DispatchResult =
  | {
      destination: "deterministic";
      intent: "order_status";
      payload: unknown;
    }
  | {
      destination: "llm";
      intent: "public_product_guidance";
      response: string;
    }
  | {
      destination: "human";
      reviewId: string;
      reason: ReviewReason;
    };

async function classifyIntent(message: string): Promise<IntentAnswer> {
  const response = await client.systemOne({
    model: "jev-latest",
    state: { message },
    questions: {
      intent: choice(
        "What is the customer's primary requested outcome in `message`?",
        {
          order_status:
            "The customer asks where an existing order is, when it will arrive, or for its current delivery or fulfillment status.",
          public_product_guidance:
            "The customer asks how to use a publicly documented product feature. The request does not require account access, payment information, a subscription change, or an action on the customer's behalf.",
          account_or_billing:
            "The customer asks about login, account access, personal account data, subscriptions, charges, invoices, payments, refunds, or any account-specific change.",
          other:
            "The message does not fit the listed categories, is too ambiguous to route safely, or contains multiple material requests that need separate handling.",
        },
      ),
    },
  });

  return response.answers.intent;
}

async function sendToReview(
  request: SupportRequest,
  deps: RouterDependencies,
  reason: ReviewReason,
  answer?: IntentAnswer,
): Promise<DispatchResult> {
  const task = await deps.createReviewTask({
    customerId: request.customerId,
    orderId: request.orderId,
    message: request.message,
    reason,
    intent: answer?.choice,
    confidence: answer?.confidence,
    probabilities: answer?.probabilities,
  });

  return {
    destination: "human",
    reviewId: task.id,
    reason,
  };
}

export async function routeSupportRequest(
  request: SupportRequest,
  deps: RouterDependencies,
): Promise<DispatchResult> {
  let answer: IntentAnswer;

  try {
    answer = await classifyIntent(request.message);
  } catch (error) {
    deps.log("jev_request_failed", {
      customerId: request.customerId,
      error,
    });

    return sendToReview(request, deps, "jev_unavailable");
  }

  deps.log("intent_classified", {
    customerId: request.customerId,
    intent: answer.choice,
    confidence: answer.confidence,
    probabilities: answer.probabilities,
  });

  if (answer.confidence < MIN_ROUTING_CONFIDENCE) {
    return sendToReview(request, deps, "low_intent_confidence", answer);
  }

  switch (answer.choice) {
    case "order_status": {
      const order = await deps.lookupOrder({
        customerId: request.customerId,
        orderId: request.orderId,
      });

      return {
        destination: "deterministic",
        intent: "order_status",
        payload: order,
      };
    }

    case "public_product_guidance": {
      try {
        const response = await deps.answerFromApprovedDocs({
          message: request.message,
        });

        return {
          destination: "llm",
          intent: "public_product_guidance",
          response,
        };
      } catch (error) {
        deps.log("guidance_llm_failed", {
          customerId: request.customerId,
          error,
        });

        return sendToReview(request, deps, "llm_unavailable", answer);
      }
    }

    case "account_or_billing":
      return sendToReview(request, deps, "account_or_billing", answer);

    case "other":
    default:
      return sendToReview(
        request,
        deps,
        "unsupported_or_ambiguous_intent",
        answer,
      );
  }
}

Read the policy, not just the branches

The essential policy decisions in this implementation are explicit and reviewable:

  1. Low confidence always routes to a person.
    The threshold is deliberately conservative. It is an initial policy value, not a universal Jev setting.

  2. order_status is deterministic.
    Once intent is sufficiently clear, the application calls a known order service. That service still applies access control and returns authoritative data.

  3. The LLM is constrained to public product guidance.
    answerFromApprovedDocs should use a curated knowledge source and should not have tools capable of issuing refunds, changing subscriptions, or accessing private records.

  4. High-confidence account and billing messages still go to review.
    Confidence answers “how clear is the category?” It does not answer “is the ensuing action safe to automate?”

  5. other is a safe escape hatch.
    A confident classification of other means the router successfully recognized that its taxonomy does not provide a supported automatic path.

The dependency name answerFromApprovedDocs is intentional. An LLM should not answer from arbitrary assumed knowledge when it represents product behavior or policy. Its prompt, context selection, output filtering, and fallback behavior belong behind that function’s interface.


Confidence is routing evidence, not permission

A common mistake is to treat a high model confidence as permission to automate anything. Instead, treat it as one input to policy:

A high-confidence account_or_billing classification therefore produces a useful outcome — a correctly prioritized review task — rather than automatic account action.

A deep dive into Jev, TypeSafe's System One model - Flavio Copes

Read Flavio Copes’s discussion of confidence-based action policy. It provides a useful three-band mental model: act automatically, use a cautious intermediate path, or do not act automatically.

In the section titled “Confidence: when to act and when to ask,” read the confidence policy discussion. Relate its low, medium, and high ranges to this router’s distinction between automatic lookup, constrained LLM guidance, and review. Treat all example thresholds as starting hypotheses that require evaluation on your own labeled requests.

In this lesson, there are only two confidence bands: automatic routing above the threshold and human review below it. That is a sensible first production version because it is easy to explain and audit.

A later version might introduce an intermediate path. For example, a moderate-confidence public_product_guidance request could prompt the user to choose between two clearly worded intents rather than immediately entering a review queue. Do not add this complexity until your logs show that it would reduce genuine ambiguity without producing a confusing user experience.


Treat human review as a first-class destination

“Human review” should not mean writing a vague note such as “model uncertain.” A useful review task should contain:

  • The original request and stable request identifier.
  • The proposed Jev intent.
  • Confidence and the full option probabilities.
  • The specific escalation reason.
  • Links to the authoritative account or order context that the reviewer is authorized to inspect.
  • A reviewer outcome, such as approve, reject, modify, request information, or reroute.

For a refund-related message, a reviewer should see the customer’s request, the relevant payment records through the normal internal tools, and the reason it was escalated. The reviewer should not need to reverse-engineer the routing decision from raw logs.

Add a Human-in-the-Loop to Your LangChain Agent (Next.js + TypeScript Tutorial)

Watch “Add a Human-in-the-Loop to Your LangChain Agent” from the LangChain channel for a concise explanation of why sensitive tool actions need an approval boundary. The framework differs from this Jev router, but the approval model is directly applicable.

Watch review mechanics. Focus on the three possible reviewer outcomes: authorization, modification, and denial. For this router, the analogous design is to create a review task before an account-affecting operation, rather than allowing either Jev or a downstream LLM to execute that operation directly.

A router does not need an agent framework or a long-running graph to implement this. In a typical web application, createReviewTask can write to a database and return a task identifier. A reviewer UI can later load that task, show the necessary context, and call separately authorized application endpoints.


Validate the router with representative cases

Before connecting this router to real customer traffic, prepare a small table of messages from your actual product domain. The goal is not to prove that one example works; it is to find boundaries where categories could compete.

MessageExpected routeWhy
“Where is my order? It was due yesterday.”Deterministic order lookupThe requested outcome is delivery status
“How do I export my workspace data?”LLM, using approved documentationPublic feature guidance without account action
“I was charged twice. I need a refund.”Human reviewBilling and a potentially financial action
“My account is broken and I want to cancel.”Human reviewMultiple account-related requests
“Can you help me?”Human reviewToo little evidence for a safe routing decision

For each test case, inspect all three outputs:

  • The chosen intent.
  • The option probabilities.
  • The confidence value.

Log them alongside the eventual correct route. That record is the beginning of the evaluation dataset you will use later to decide whether 0.78 is too strict, too permissive, or appropriate for your traffic.

Also test the non-model paths deliberately:

  • Jev is unavailable.
  • The LLM service times out.
  • answerFromApprovedDocs returns no supported answer.
  • A customer is authenticated but does not own the supplied order.
  • The message contains prompt-injection-like text instructing the system to skip review.

The router should remain safe because no free-text instruction can change the deterministic policy encoded in the switch statement and the downstream authorization checks.


Key takeaways

An intent router is a constrained decision service, not an autonomous support agent:

  • Use Jev Choice to identify a bounded, operationally meaningful request category.
  • Keep routing thresholds, side effects, authorization, and escalation policy in TypeScript.
  • Dispatch safe, well-defined intents to deterministic functions.
  • Restrict LLM use to non-sensitive generation with approved context.
  • Route low-confidence, high-risk, ambiguous, and unsupported cases to a reviewer.
  • Log the choice, probabilities, confidence, and eventual outcome so that thresholds can be evaluated rather than guessed.

Next, you will apply the same pattern to a confidence-gated moderation or safety workflow, where the consequences of a false positive and a false negative make the policy boundary especially important.

Can't find a good explanation? Sign up and we'll make it for you

Sign up