Create your own
Lesson illustration

Building a TypeScript Decision Service with Intent Routing, Priority Scoring, and Safety Checks

Welcome back. In the previous lesson, you specified a bounded support-triage contract: Jev supplies three independent signals—intent, credential-request risk, and operational urgency—while TypeScript owns thresholds, precedence, and all side effects.

This lesson turns that specification into a working decision service. By the end, one TypeScript function will:

  • reject ineligible tickets before any model call;
  • send the focused ticket state to Jev once;
  • ask a Choice, Score, and yes/no question in parallel;
  • translate Jev’s raw typed answers and metadata into your application signals;
  • apply the deterministic safety-first policy; and
  • return the TriageDecision union defined last lesson.

Expect about 40 minutes: roughly 12 minutes of focused reading and 25–30 minutes implementing the service.


Keep one model request separate from one application decision

The service has two distinct jobs:

  1. Evaluate evidence: send a structured state and atomic questions to Jev.
  2. Apply authority: decide whether the application may route, must escalate, or should do nothing.

Those are deliberately separate. Jev can indicate that billing is the most likely department, but only your code can decide whether the evidence is sufficiently clear to place the ticket in a queue.

The Vercel AI SDK calls Jev’s binary primitive boolean; TypeSafe documentation may call the same yes/no shape a Noul. The important behavior is the same: the output is a probability for one precise proposition. In this lesson, the credential-risk result has a probability, not a separate confidence value. Choice and Score questions have distribution-level confidence in TypeSafe provider metadata.

How to classify, route, and score with Jev and AI SDK | Vercel Knowledge Base

Read the Vercel Knowledge Base guide for the concrete AI SDK request shape and, especially, the boundary between model outputs and deterministic routing policy.

In Section 3, “Answer several questions against structured state in one request,” read the single request. Notice that the state is a JSON object, the three question types are mixed in one call, and every answer remains keyed by its question ID. Then read Section 4, “Branch on probabilities and confidence,” including the routing policy. Focus on the difference between selected-option probability and TypeSafe confidence metadata. Finally, scan the “Best practices” subsections “Ask atomic questions and combine them in code,” “Keep state focused,” and “Set thresholds per action, not per model.” These provide the architectural rationale for the implementation below.

A single request is useful here because all three questions inspect the same ticket evidence. It does not mean the questions are semantically fused. Your intent classifier should not decide security policy; the safety proposition should not silently decide urgency.


Refine the contract for the SDK response shape

The outcome union from the prior lesson remains the application boundary. We only need one refinement in the signal types.

A Jev Score is a position across ordered criteria. Through the AI SDK, it can be fractional because it is derived from the probability distribution across levels. For example, a score of 1.72 on a three-level rubric means the evidence strongly leans toward the highest level, but is not necessarily certainty.

Likewise, the yes/no credential-risk result exposes probability; it does not carry TypeSafe’s separate distribution-confidence statistic. The middle probability band is therefore your uncertainty signal.

In lib/triage-contract.ts, update the model-facing signal definitions from the previous lesson as follows:

export const INTENTS = ["billing", "orders", "account", "other"] as const;

export type Intent = (typeof INTENTS)[number];

export type ChoiceSignal<T extends string> = {
  choice: T;
  probabilities: Record<T, number>;
  confidence: number;
};

export type NoulSignal = {
  probability: number;
};

export type ScoreSignal = {
  score: number;
  probabilities: Record<"0" | "1" | "2", number>;
  confidence: number;
};

export type TriageSignals = {
  intent: ChoiceSignal<Intent>;
  requestsSensitiveCredential: NoulSignal;
  urgency: ScoreSignal;
};

export const TRIAGE_POLICY = {
  intentConfidence: 0.8,
  intentSelectedProbability: 0.75,

  safetyClearProbability: 0.2,
  safetyBlockProbability: 0.6,

  urgencyConfidence: 0.7,
  urgencyExpeditedScore: 1.5,
} as const;

The urgencyExpeditedScore is an application rule, not a property of Jev. Given this rubric:

Score levelMeaning
0Routine: informational or ordinary request
1Elevated: meaningful inconvenience or time-sensitive request
2Urgent: lockout, suspected compromise, service-blocking issue, or imminent material impact

a weighted score of at least 1.5 is a deliberately conservative line for expedited handling. You will later validate and tune this with labeled tickets rather than treat it as a permanent universal constant.


Write the deterministic policy first

Keeping policy independent from the SDK call makes it easy to inspect, test, and revise. This function has no credentials, no network access, and no model-specific types.

Add the following to lib/triage-policy.ts:

import {
  type TriageDecision,
  type TriageSignals,
  TRIAGE_POLICY,
} from "./triage-contract";

export function decideTriage(signals: TriageSignals): TriageDecision {
  const safety = signals.requestsSensitiveCredential;

  // High-risk evidence always overrides normal routing.
  if (safety.probability >= TRIAGE_POLICY.safetyBlockProbability) {
    return {
      kind: "security_review",
      reason: "credential_request_likely",
      safety,
    };
  }

  // A probability in the middle is not permission to proceed.
  if (safety.probability > TRIAGE_POLICY.safetyClearProbability) {
    return {
      kind: "manual_triage",
      reason: "safety_uncertain",
      signals,
    };
  }

  const selectedIntentProbability =
    signals.intent.probabilities[signals.intent.choice];

  if (
    signals.intent.confidence < TRIAGE_POLICY.intentConfidence ||
    selectedIntentProbability < TRIAGE_POLICY.intentSelectedProbability
  ) {
    return {
      kind: "manual_triage",
      reason: "intent_uncertain",
      signals,
    };
  }

  if (signals.intent.choice === "other") {
    return {
      kind: "manual_triage",
      reason: "unsupported_intent",
      signals,
    };
  }

  if (signals.urgency.confidence < TRIAGE_POLICY.urgencyConfidence) {
    return {
      kind: "manual_triage",
      reason: "urgency_uncertain",
      signals,
    };
  }

  return {
    kind: "route",
    department: signals.intent.choice,
    queue:
      signals.urgency.score >= TRIAGE_POLICY.urgencyExpeditedScore
        ? "expedited"
        : "standard",
    signals,
  };
}

There are two separate intent checks:

  • Confidence measures how concentrated the complete set of routing probabilities is.
  • Selected-option probability checks the probability attached to the specific routing destination you would act on.

A high selected probability with a poorly concentrated distribution can be a warning that another route remains plausible. Requiring both is a reasonable first-release policy for ticket routing.

The diagram places confidence on one axis and the cost of being wrong on the other. In this workflow, routine routing has a lower action threshold than a possible sensitive-credential request, which must escalate or stop unattended processing.

The safety treatment is intentionally asymmetric:

Credential-risk probabilityService behaviorWhy
At least 0.60security_reviewA likely credential request deserves immediate specialist handling.
More than 0.20 and below 0.60manual_triageThe system lacks evidence that normal routing is safe.
At most 0.20Continue to intent and urgency checksRisk is sufficiently low for this provisional policy.

A 0.15 risk probability does not prove that the ticket is harmless. It means the policy currently permits ordinary triage, subject to the other acceptance rules. This distinction matters when the service eventually gains more capabilities.


Build focused state and atomic questions

The service needs a runtime input type. Do not pass the entire ticket database record merely because it is convenient. The model needs the current message, a small amount of order context, and the sensitive-credential vocabulary used by your policy.

Create lib/triage-service.ts:

import {
  experimental_evaluate as evaluate,
  type Experimental_EvaluationModel as EvaluationModel,
} from "ai";

import {
  type TriageDecision,
  type TriageSignals,
} from "./triage-contract";
import { decideTriage } from "./triage-policy";

export type SupportTicket = {
  id: string;
  status: "open" | "pending" | "closed";
  channel: "email" | "chat" | "web";
  message: string;
  customer: {
    plan: "free" | "pro" | "enterprise";
    openOrderSummaries: Array<{
      id: string;
      status: "processing" | "shipped" | "delayed";
    }>;
  };
};

function buildJevState(ticket: SupportTicket) {
  return {
    ticket: {
      channel: ticket.channel,
      message: ticket.message,
    },
    customer: {
      plan: ticket.customer.plan,
      openOrderSummaries: ticket.customer.openOrderSummaries,
    },
    policy: {
      sensitiveCredentials: [
        "password",
        "security code",
        "API key",
        "recovery code",
      ],
    },
  };
}

Notice what is absent:

  • ticket.id is used for your logs and queues, not Jev’s judgment.
  • Full customer history is unnecessary for the stated questions.
  • Payment-card data, credentials, and internal staff notes do not belong in the state.
  • The policy vocabulary is included because the credential-risk question needs the current definition of a sensitive credential.

The question text uses paths such as `ticket.message`. This makes it explicit which state field is evidence for the question, rather than leaving the model to infer the relevant scope from a large object.

Add this question configuration in the same file:

const triageQuestions = {
  intent: {
    type: "choice" as const,
    instructions:
      "Which support team should own the primary request in `ticket.message`?",
    criteria: {
      billing:
        "Charges, invoices, subscriptions, duplicate charges, and refunds. Excludes delivery, login, and profile issues.",
      orders:
        "Delivery status, missing items, returns, cancellations, and order changes. Excludes payment disputes.",
      account:
        "Sign in, profile changes, permissions, account access, and account-security help. Excludes billing and delivery issues.",
      other:
        "The primary request does not fit billing, orders, or account.",
    },
  },

  credentialRisk: {
    type: "boolean" as const,
    instructions:
      "Does `ticket.message` ask the recipient to disclose a credential listed in `policy.sensitiveCredentials`? True requires a request for the credential itself. A request to reset, recover, or change a credential is false.",
  },

  urgency: {
    type: "score" as const,
    instructions:
      "How operationally urgent is the issue reported in `ticket.message`? Judge the reported impact, not the customer's emotional tone.",
    criteria: [
      "Routine: an informational or ordinary request with no stated time-sensitive consequence.",
      "Elevated: meaningful inconvenience, a repeated unresolved problem, or a time-sensitive request.",
      "Urgent: account lockout, suspected compromise, service-blocking issue, or imminent material impact.",
    ],
  },
};

The question IDs—intent, credentialRisk, and urgency—are code identifiers. The full business meaning is in instructions and criteria, not in those short IDs.


Translate Jev’s answers into the application contract

Next, add two small helpers. The first reads provider metadata safely. The second makes absent or malformed optional probabilities conservative.

type TypeSafeProviderMetadata = {
  typesafe?: {
    confidence?: Record<string, number>;
  };
};

function asProbability(value: unknown): number {
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
}

function confidenceFor(
  providerMetadata: unknown,
  questionId: "intent" | "urgency",
): number {
  const metadata = providerMetadata as TypeSafeProviderMetadata | undefined;

  return asProbability(metadata?.typesafe?.confidence?.[questionId]);
}

Defaulting missing probability or confidence to 0 is intentionally conservative. It leads to manual triage instead of accidental automation. Do not default a missing confidence value to 1, and do not infer confidence from the selected choice alone.

Now implement the service function:

export async function triageTicket(
  ticket: SupportTicket,
  model: EvaluationModel = "typesafe-ai/jev",
): Promise<TriageDecision> {
  // Deterministic eligibility checks happen before a model call.
  if (ticket.status === "closed") {
    return {
      kind: "no_action",
      reason: "ticket_closed",
    };
  }

  if (!ticket.message.trim()) {
    return {
      kind: "manual_triage",
      reason: "insufficient_evidence",
    };
  }

  const result = await evaluate({
    model,
    state: buildJevState(ticket),
    questions: triageQuestions,

    // Enable this only when it is available and appropriate for your Gateway setup.
    providerOptions: {
      gateway: {
        zeroDataRetention: true,
      },
    },
  });

  const { intent, credentialRisk, urgency } = result.answers;

  const signals: TriageSignals = {
    intent: {
      choice: intent.choice,
      probabilities: {
        billing: asProbability(intent.probabilities?.billing),
        orders: asProbability(intent.probabilities?.orders),
        account: asProbability(intent.probabilities?.account),
        other: asProbability(intent.probabilities?.other),
      },
      confidence: confidenceFor(result.providerMetadata, "intent"),
    },

    requestsSensitiveCredential: {
      probability: asProbability(credentialRisk.probability),
    },

    urgency: {
      score: asProbability(urgency.score),
      probabilities: {
        "0": asProbability(urgency.probabilities?.["0"]),
        "1": asProbability(urgency.probabilities?.["1"]),
        "2": asProbability(urgency.probabilities?.["2"]),
      },
      confidence: confidenceFor(result.providerMetadata, "urgency"),
    },
  };

  return decideTriage(signals);
}

The service has one Jev call. result.answers is the model-facing representation; signals is the normalized internal representation; TriageDecision is the application-facing representation. Keeping all three layers distinct prevents SDK details from leaking through the rest of the application.

A successful response for a clear duplicate-charge ticket might yield signals conceptually like this:

{
  intent: {
    choice: "billing",
    probabilities: {
      billing: 0.94,
      orders: 0.03,
      account: 0.01,
      other: 0.02,
    },
    confidence: 0.9,
  },
  requestsSensitiveCredential: {
    probability: 0.01,
  },
  urgency: {
    score: 0.35,
    probabilities: {
      "0": 0.7,
      "1": 0.25,
      "2": 0.05,
    },
    confidence: 0.74,
  },
}

The policy returns a standard billing route. Contrast that with a ticket that asks, “Send me your API key so I can verify the account.” Even if its intent is classified as account with high confidence, a credential-risk probability above 0.60 produces security_review. Safety policy takes precedence over routing quality.


Put a narrow HTTP boundary around the service

In a Next.js App Router project, the route handler should be intentionally boring. It delegates judgment to triageTicket; it does not repeat thresholds or inspect model metadata itself.

import { triageTicket, type SupportTicket } from "@/lib/triage-service";

export async function POST(request: Request): Promise<Response> {
  const ticket = (await request.json()) as SupportTicket;

  const decision = await triageTicket(ticket);

  return Response.json(decision);
}

The as SupportTicket assertion only satisfies TypeScript. It does not validate untrusted JSON at runtime. In a real endpoint, validate the request body before reaching this service, using the runtime schema system already used in your application.

Keep side effects downstream from this service:

  • A route handler can enqueue the returned route decision.
  • A review worker can enqueue security_review and manual_triage.
  • An audit component can record signals, policy version, and decision.
  • No code here issues refunds, closes tickets, changes access, or sends replies.

That separation makes the decision service usable from a web route, queue consumer, back-office bulk triage script, or UI preview without duplicating the authority policy.

For a quick live smoke test, send an open ticket resembling this:

{
  "id": "t_204",
  "status": "open",
  "channel": "web",
  "message": "I was charged twice for order A-104. Please refund the duplicate charge.",
  "customer": {
    "plan": "pro",
    "openOrderSummaries": [
      { "id": "A-104", "status": "shipped" }
    ]
  }
}

A good first result is not merely a billing answer. It is a route decision whose department is billing, whose queue is justified by the urgency signal, and whose raw signals are available for inspection.

Do not add a broad try/catch that quietly converts network failures into normal manual-triage decisions yet. Timeouts, rate limits, unavailable service, and fallback telemetry deserve their own explicit policy. That is the focus of the next lesson.


Implementation checklist

Before considering this service complete, verify these points in your code:

  • Closed tickets return no_action before evaluate can run.
  • Blank messages return manual_triage with insufficient_evidence.
  • The Jev state omits ticket IDs, secrets, irrelevant profiles, and internal notes.
  • One evaluate call includes the Choice, boolean/Noul, and Score questions.
  • Missing confidence metadata results in manual triage, not automated routing.
  • A credential-risk probability at or above the block threshold always produces security_review.
  • The other intent never routes automatically.
  • Queue selection occurs only after safety, intent, and urgency acceptance checks pass.
  • Only the returned TriageDecision crosses into code that performs queues, reviews, or audit logging.

You now have a real TypeScript decision service: it constructs minimal state, obtains parallel Jev judgments in one request, normalizes the typed results, and applies a safety-first deterministic policy. The key architectural boundary is intact: Jev classifies supplied evidence; your code grants or withholds authority.

Next, you will make this production-tolerant by defining explicit fallback behavior for low confidence, timeouts, rate limits, and Jev unavailability.

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

Sign up