Blog
Decision Models in Symfony AI: Put Confidence to Work
Which team should handle this ticket? Does this tool call need a human review? Decision models turn questions like these into structured answers with probabilities. Let’s connect Jev to Symfony AI and put the threshold where we can test it: in our application code.
Updated symfony ai decision-models 14 min read
By David Gorges
Managing Director
Meet the model that makes a choice
Imagine asking a model which team should handle a support ticket. You already know the possible answers. You do not need three paragraphs explaining its feelings about the billing department. You need a choice, and a useful measure of how confident it is.
That is the job of a decision model. Give it a state, as text or JSON, plus one or more questions with predefined answers. It returns a structured result with probabilities for the available options. No generated prose to parse, and no surprise answer outside your list.
TypeSafe AI, founded by former OpenAI researcher Diogo Almeida, introduced the category with Jev. Its API defines three question types that other implementations have adopted:
- Choice: pick from up to 255 named options. You get the selected option, a probability for each option, and a confidence value.
- Score: rate something on an ordered scale, such as “calm” through “very angry,” with a probability for each level.
- Noul: answer a yes-or-no question with a probability between 0 and 1.
You can ask several questions about the same state in one call. Because these models do not generate output tokens, services such as Jev can charge for the input alone. Check each provider’s pricing rather than assuming the same billing model everywhere. TypeSafe reports response times of 70 to 500 milliseconds for Jev. Laya, an open model with 322 million parameters, reports 21 milliseconds per decision on a laptop GPU. Compare those figures with the roughly 1.6 seconds cited for a regular GPT-6 Luna call, while remembering that hardware and workloads matter.
TypeSafe calls Jev a “System One Model,” borrowing Daniel Kahneman’s term for fast, intuitive thinking. The name Jev comes from economist William Stanley Jevons: make a resource cheaper, and people may use much more of it. The bet here is simple. When a judgment costs a tiny fraction of a cent, applications will make a lot more judgments.
Why now?
The underlying idea has been around for years. A BERT-based classifier already chooses among categories. Ask a language model for an option letter and inspect its log probabilities, and you can also get a distribution over answers. What makes this wave interesting is the combination of three things.
First, zero-shot flexibility. You describe the question and its options in natural language at runtime, without building a training set for every new task.
Second, calibration. TypeSafe trains Jev using a method it calls Reinforcement Learning for Calibrated Decisions. The probabilities are optimized against observed outcomes. If the model assigns an outcome a probability of 0.9, you want that outcome to occur about nine times out of ten across comparable predictions. That is much more useful than a model sounding very sure of itself.
Third, demand. Structured output gave us schema-valid responses, but it did not, by itself, give us reliable confidence estimates. LLM-as-judge evaluations can be slow and expensive. Agents add even more small decisions to every task: Which tool? Which model? Are we done? Should this action be allowed?
The early adoption numbers suggest that developers felt this gap. Vercel reported that nearly 13 percent of paying AI Gateway teams used Jev within 24 hours, twice the adoption of the GPT-5.6 family and six times that of Fable 5.1. TypeSafe has reported workflows up to 194 times faster and 445 times cheaper, while acknowledging that these are upper-end results rather than everyday expectations. Treat them as a reason to run your own benchmark, not as a promise for your application.
Within four weeks, AWS, Cloudflare, OpenAI, and Microsoft shipped decision models of their own. That story, and the questions skeptics are asking, lives in a companion post: Decision Models: Who Launched What, and What to Ask. Here, we write code.
Let’s wire it into Symfony AI
Symfony AI added Jev support in version 0.14.0 through symfony/ai-type-safe-platform. This is a Platform bridge: Jev does not accept chat messages, call tools, or stream a response. Instead, you send an Evaluation containing your state and questions.
Start by installing the bundle and the bridge:
composer require symfony/ai-bundle symfony/ai-type-safe-platform
ai:
platform:
typesafe:
api_key: '%env(TYPESAFE_API_KEY)%'
Now for a concrete example: support-ticket triage. One ticket gives us a useful way to try all three question types. Which department should handle it? How frustrated is the customer? Is it urgent?
The result values in the comments below are illustrative. They are not a guarantee of what the model will return for this ticket.
<?php
declare(strict_types=1);
use Symfony\AI\Platform\Bridge\TypeSafe\Evaluation;
use Symfony\AI\Platform\Bridge\TypeSafe\Factory;
use Symfony\AI\Platform\Bridge\TypeSafe\Question\ChoiceQuestion;
use Symfony\AI\Platform\Bridge\TypeSafe\Question\NoulQuestion;
use Symfony\AI\Platform\Bridge\TypeSafe\Question\ScoreQuestion;
$platform = Factory::createPlatform($_ENV['TYPESAFE_API_KEY']);
$ticket = <<<'TXT'
Subject: Charged twice for the same invoice
You charged me twice for September. I need the money back
by Friday or I can't pay my rent. This is the second time.
TXT;
$result = $platform->invoke('jev-1.13.0', new Evaluation($ticket, [
'department' => new ChoiceQuestion(
'Which team should handle this ticket?',
[
'billing' => 'Payments, invoices, subscriptions',
'technical' => 'Bugs, integrations, outages',
'sales' => 'Pricing, contracts, quotes',
],
),
'frustration' => new ScoreQuestion(
'How frustrated is the customer?',
[
'Calm, stating the facts',
'Frustrated, but matter-of-fact',
'Very angry, using strong language',
],
),
'is_urgent' => new NoulQuestion(
'Does this message require a response within one day?',
),
]));
$answers = $result->asObject();
$department = $answers->getChoice('department');
// "billing"
$department->getChoice();
// ['billing' => 0.97, 'technical' => 0.02, 'sales' => 0.01]
$department->getProbabilities();
// close to 2: the probability-weighted level
$answers->getScore('frustration')->getScore();
// 0.93
$answers->getNoul('is_urgent')->getProbability();
// true: an explicit threshold instead of the default 0.5
$answers->getNoul('is_urgent')->isTrue(0.9);
Which team should handle this ticket?
Does this message require a response within one day?
meets threshold 0.90Put the threshold in your code
Getting a probability back is the beginning. Your application still needs a policy for what happens next.
That policy is a threshold, and it belongs in tested code: when the probability of the chosen option meets it, the ticket takes the automatic path; below it, a person decides. Log the model identifier and the version of your questions with every result, so you can trace a decision back to the instructions that produced it.
Every threshold is a trade-off, and it is easier to feel than to read about. Below are 50 made-up decisions, each placed at the probability the model gave its answer. Drag the threshold and watch how many cases run automatically, how many wrong answers slip through with them, and how many land with a person.
Threshold 0.80
- Automatic
- 86% 43 / 50
- Wrong, but automatic
- 4 / 43
- To a person
- 14% 7 / 50
Here is what that looks like in a Messenger handler. Three details matter, so pick a note below the code to see where each one lives:
<?php
declare(strict_types=1);
namespace App\Support\Triage;
use Psr\Log\LoggerInterface;
use Symfony\AI\Platform\Bridge\TypeSafe\Evaluation;
use Symfony\AI\Platform\Bridge\TypeSafe\Question\ChoiceQuestion;
use Symfony\AI\Platform\Bridge\TypeSafe\Question\NoulQuestion;
use Symfony\AI\Platform\PlatformInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class TriageTicketHandler
{
/** Below this probability, a person decides. Tune it on your data. */
private const float MIN_PROBABILITY = 0.85;
private const float URGENT_THRESHOLD = 0.9;
private const string MODEL = 'jev-1.13.0';
private const string QUESTIONS_VERSION = 'triage-v3';
public function __construct(
#[Autowire(service: 'ai.platform.typesafe')]
private PlatformInterface $platform,
private TicketRepository $tickets,
private TicketRouter $router,
private LoggerInterface $logger,
) {
}
public function __invoke(TriageTicket $message): void
{
$ticket = $this->tickets->get($message->ticketId);
$evaluation = new Evaluation($ticket->body(), [
'department' => new ChoiceQuestion(
'Which team should handle this ticket?',
Department::asOptions(),
),
'is_urgent' => new NoulQuestion(
'Does this message require a response within one day?',
),
]);
$answers = $this->platform
->invoke(self::MODEL, $evaluation)
->asObject();
$department = $answers->getChoice('department');
$choice = $department->getChoice();
$probabilities = $department->getProbabilities();
$urgent = $answers->getNoul('is_urgent');
$this->logger->info('ticket.triage', [
'ticket' => $ticket->id(),
'questions' => self::QUESTIONS_VERSION,
'model' => self::MODEL,
'department' => $probabilities,
'urgent' => $urgent->getProbability(),
]);
if ($probabilities[$choice] < self::MIN_PROBABILITY) {
$this->router->toHumanReview($ticket, 'Below threshold');
return;
}
$this->router->assign(
$ticket,
Department::from($choice),
urgent: $urgent->isTrue(self::URGENT_THRESHOLD),
);
}
}
-
Named thresholds lines 18–22, 63–67
The thresholds are named constants, and the check uses the probability of the chosen option: the same number as the slider above and the calibration check below. A value that looks reassuring in a code sample has not earned its place in production until your own evaluation data backs it.
-
One list of options lines 40, 71
The choice options come from the same
Departmentenum that the application reads throughDepartment::from(). There is no second list in a prompt quietly drifting away from your code. -
Log the whole distribution lines 55–61
The log records the full probability distribution. Keeping only the winning label throws away the information you need to check whether the probabilities remain calibrated as your tickets change.
Use a Workflow guard before a tool call
Another useful place for a decision model is just before an agent takes an action. Given a proposed tool call, we can ask whether it appears safe and whether it serves the task. Strands Agents describes this as an intervention before the tool call.
In Symfony, a Workflow guard is a natural place for this check. Every caller going through that transition encounters the same guard:
<?php
declare(strict_types=1);
namespace App\Agent;
use Symfony\AI\Platform\Bridge\TypeSafe\Evaluation;
use Symfony\AI\Platform\Bridge\TypeSafe\Question\NoulQuestion;
use Symfony\AI\Platform\PlatformInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Workflow\Event\GuardEvent;
#[AsEventListener(event: 'workflow.agent_task.guard.execute_tool')]
final readonly class ToolCallGuard
{
private const float MIN_SAFE = 0.95;
private const float MIN_ON_TASK = 0.9;
private const string MODEL = 'jev-1.13.0';
public function __construct(
#[Autowire(service: 'ai.platform.typesafe')]
private PlatformInterface $platform,
) {
}
public function __invoke(GuardEvent $event): void
{
/** @var AgentTask $task */
$task = $event->getSubject();
// The state is JSON: the task, the proposed call, its arguments.
$evaluation = new Evaluation($task->proposedToolCall(), [
'is_safe' => new NoulQuestion(
'Is this call free of irreversible consequences'
.' for data or other people?',
),
'on_task' => new NoulQuestion(
'Does this call serve the assigned task?',
),
]);
$answers = $this->platform
->invoke(self::MODEL, $evaluation)
->asObject();
$safe = $answers->getNoul('is_safe')->getProbability();
$onTask = $answers->getNoul('on_task')->getProbability();
if ($safe < self::MIN_SAFE || $onTask < self::MIN_ON_TASK) {
$event->setBlocked(true, sprintf(
'Decision model: safe=%.2f, on_task=%.2f',
$safe,
$onTask,
));
}
}
}
The guard blocks the transition when either score falls below its threshold. Your workflow must provide the human-review path, and the tool must execute only after the transition is allowed. Blocking a transition does not create an approval queue by itself.
Check provider compatibility before switching implementations. The bridge documented here targets TypeSafe and ships the Jev identifiers jev-latest, jev-preview, and jev-1.13.0. Liquid d1, Cloudflare’s Clef, Jeeves, Laya, and Strands Decider offer similar interfaces, but that does not establish that this bridge can connect to them unchanged. Our overview of decision models lists who offers what.
OpenAI’s dedicated /v1/decisions endpoint also needs its own integration; similar question types do not make it a drop-in replacement for the TypeSafe bridge.
The examples depend on PlatformInterface, which gives you a useful boundary for adapting another provider or a self-hosted model. Validate the replacement’s API behavior and recalibrate the thresholds before making the switch.
Where we would use decision models
Our architecture has long followed a simple principle: the model proposes, and tested code decides what happens. Until now, getting that proposal often meant a full LLM call with structured output. A decision model can make those small judgments much faster and attach probabilities that the application can evaluate.
Here are the places we would test first:
- Incoming documents: choose a processing pipeline and estimate risk before asking an LLM to extract fields. Some routine documents may not need a generative model at all.
- Approval queues: our agents prepare drafts, and people approve them. A calibrated decision about which cases need review could reduce the queue where automatic handling is already permitted. The share that can safely pass depends on the task and the evaluation results.
- Interview and survey evaluation: score answers against a rubric and retain the distribution across levels. Check that the result is suitable for the intended use before replacing an existing evaluator.
- Test-suite evaluations: benchmark a decision model against the current LLM-as-judge implementation, with the goal of making evaluation cheap and fast enough to run on every deployment.
- Model routing: use a small model to help choose between a cheaper model and a larger one before a generative call. At sufficient volume, that can have a substantial effect on the bill.
The pattern is straightforward: decision models for bounded judgments, language models for prose, and tested application code for the consequences.
Your first experiment
-
Build an evaluation set.
Start with 200 anonymized examples from your own intake or triage process, with the expected answers. Treat that as a starting point, and expand it to cover rare and costly mistakes. Without representative examples, thresholds are guesses.
-
Measure calibration as well as accuracy.
Group predictions by the probability assigned to the chosen option, then compare each group’s accuracy with that probability: if your 0.9 bucket is right only seven times out of ten, your threshold is too optimistic. Weigh the result against the cost of errors and your approval requirements before you automate anything.
-
Keep the call behind an interface.
This ecosystem is young, and the pricing described here is still unsettled. Today’s hosted Jev integration might become a self-hosted Strands Decider deployment later. Give yourself a clean place to make that change.
-
Choose where the data is processed.
For personal data that must remain on your infrastructure, evaluate local models. Laya can run on a CPU, and Strands Decider has reported MacBook results. Use hosted services only for data you are permitted to send to them.
-
Log the distribution.
Keep all option probabilities, the question version, and the model identifier. Those records help you detect drift before customers notice it.
Jev’s name points back to the Jevons paradox: cheaper decisions may lead to many more automated decisions. All the more reason to get the threshold, the guard, and the logging right. That is how a neat demo becomes something you can responsibly operate.
David Gorges is managing director of Dekkode in Hamburg, where we build Symfony applications and AI features. If you put a decision model behind a threshold, we’d love to hear how it went: write to us. To get the next post, follow the blog’s RSS feed.
Sources
15 sources
-
[AI Bundle][Platform] Add TypeSafe bridge
symfony/ai PR #2549
-
symfony/ai releases
GitHub
-
TypeSafe documentation
TypeSafe
-
TypeSafe confidence semantics
TypeSafe
-
Jev (AI model)
Wikipedia
-
Laya
brainfunctioncollapse.com
-
Introducing Strands Decider
Strands Labs (AWS) · October 1, 2026
-
Introducing Clef: our open-source decision models
Cloudflare · October 1, 2026
-
Decision models
Liquid AI
-
PostHog Jeeves
GitHub
-
OpenAI Decisions API
OpenAI · official guide, checked October 7, 2026
-
Typesafe AI raises $870M at $7.5B
Hacker News · anecdotal user reports
Updated 10 October 2026.