Route Support Tickets with Jev: Confidence-Gated Triage in Laravel

Jev Laravel ticket triage: route support tickets with typed Choice answers and calibrated confidence, so your code only auto-assigns when the model is certain.

Steven Richardson
Steven Richardson
· 11 min read

Every LLM ticket-routing system I've been handed has the same three problems: it takes seconds per ticket, it returns a string that sometimes names a department that doesn't exist, and it never says "I'm not sure." TypeSafe's Jev fixes all three — it's a System One model, so you send state plus typed questions and get back a typed answer with a calibrated confidence number attached. Here's the full Laravel flow: inbound ticket, one queued job, one request, and routing logic that's just match (true).

Define the departments as a backed enum#

Jev's Choice question takes a criteria map where each key is an option name and each value describes that option. That maps one-to-one onto a PHP backed enum, which means the answer coming back is a string you can feed straight into tryFrom() — no parsing, no string matching, no invented departments. Put the rubric text on the enum itself so the model and your code can never drift apart.

<?php

declare(strict_types=1);

namespace App\Support\Triage;

enum Department: string
{
    case Returns = 'returns';
    case Shipping = 'shipping';
    case Billing = 'billing';
    case Technical = 'technical';

    /**
     * The rubric description Jev sees for this option.
     */
    public function description(): string
    {
        return match ($this) {
            self::Returns => 'Exchanges, wrong or damaged items, refunds for goods already received. Not for card charges.',
            self::Shipping => 'Delivery status, delays, lost or misdelivered packages. Not for refunds.',
            self::Billing => 'Card charges, invoices, duplicate payments, subscription and plan changes.',
            self::Technical => 'The site or app is broken: errors, failed logins, checkout that will not submit.',
        };
    }

    /**
     * @return array<string, string>
     */
    public static function criteria(): array
    {
        return array_column(
            array_map(
                fn (self $case): array => [$case->value, $case->description()],
                self::cases(),
            ),
            1,
            0,
        );
    }
}

Note the Not for clauses. Returns and Billing both involve money and both get mentioned in the same angry email, so each description says explicitly what belongs to its neighbour. That single sentence is the difference between 0.42 confidence and 0.95. If you haven't leaned on enums this hard before, backed enums on Laravel models covers the casting side.

Build the Choice, Noul and Score questions#

Jev evaluates every question in the request against the same state, in parallel. Adding questions barely moves the response time and output tokens are free, so ask everything your routing code might need in one go rather than firing three requests. Department is a Choice, urgency is a Noul (yes/no, returns a probability), and frustration is a Score over ordered levels.

<?php

declare(strict_types=1);

namespace App\Support\Triage;

final class TriageQuestions
{
    /**
     * @return array<string, array<string, mixed>>
     */
    public static function all(): array
    {
        return [
            'department' => [
                'type' => 'choice',
                'instructions' => 'Which team should handle this ticket?',
                'criteria' => Department::criteria(),
            ],
            'is_blocked' => [
                'type' => 'noul',
                'instructions' => 'Is the customer blocked right now, losing money or unable to use the product?',
                'criteria' => [
                    'true' => 'Service is down, money is missing, or a deadline is at risk',
                    'false' => 'A question or an inconvenience that can wait until tomorrow',
                ],
            ],
            'frustration' => [
                'type' => 'score',
                'instructions' => 'How frustrated is the customer?',
                'criteria' => [
                    'Calm and factual',
                    'Mildly annoyed',
                    'Clearly frustrated',
                    'Angry, threatening to leave or escalate',
                ],
            ],
        ];
    }
}

A Noul answer comes back as a single noul float from 0 to 1 and carries no confidence — the two-outcome distribution already describes itself, so you test distance from the middle instead. A Score returns a probability-weighted score that can land between levels, plus a legend mapping level indices back to your descriptions.

Send the ticket to Jev from a queued job#

There's no official PHP SDK yet — TypeSafe ships Python and JavaScript clients — so the integration is a thin wrapper over POST https://api.typesafe.ai/v1/systemone. About thirty lines does it. Keep it synchronous inside a queued job rather than blocking the request that created the ticket.

<?php

declare(strict_types=1);

namespace App\Support\Triage;

use Illuminate\Support\Facades\Http;

final readonly class Jev
{
    public function __construct(
        private string $apiKey,
        private string $model = 'jev-latest',
    ) {}

    /**
     * @param  string|array<string, mixed>  $state
     * @param  array<string, array<string, mixed>>  $questions
     * @return array<string, array<string, mixed>>
     */
    public function ask(string|array $state, array $questions): array
    {
        $response = Http::withToken($this->apiKey)
            ->timeout(15)
            // 429 and 529 are the documented back-off cases.
            ->retry(3, fn (int $attempt): int => $attempt * 250)
            ->post('https://api.typesafe.ai/v1/systemone', [
                'state' => $state,
                'model' => $this->model,
                'questions' => $questions,
            ])
            ->throw();

        return $response->json('answers');
    }
}

The job itself passes structured state rather than a blob of text. Jev accepts a JSON object as state, and labelled fields give the model more to work with than a concatenated string.

<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Models\Ticket;
use App\Support\Triage\Jev;
use App\Support\Triage\TicketRouter;
use App\Support\Triage\TriageQuestions;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;

final class TriageTicket implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;

    public function __construct(public Ticket $ticket) {}

    /**
     * @return array<int, int>
     */
    public function backoff(): array
    {
        return [5, 20, 60];
    }

    public function handle(Jev $jev, TicketRouter $router): void
    {
        $answers = $jev->ask(
            state: [
                'subject' => $this->ticket->subject,
                'body' => $this->ticket->body,
                'plan' => $this->ticket->customer->plan,
                'previous_tickets' => $this->ticket->customer->tickets()->count(),
            ],
            questions: TriageQuestions::all(),
        );

        $router->route($this->ticket, $answers);
    }

    public function failed(?Throwable $exception): void
    {
        // Never drop a ticket because a third-party API was down.
        $this->ticket->update(['triage_state' => 'needs_human']);
    }
}

That failed() method is the whole fallback policy: if Jev is unreachable after three attempts, the ticket lands in the human queue instead of vanishing. Dispatch the job with dispatch(new TriageTicket($ticket))->afterCommit() so the worker can't pick it up before the row exists — the reasoning behind that is in dispatching jobs after database transactions commit. If you're running a high enough ticket volume to brush the 40-requests-per-second rate limit, job middleware for rate limiting and backoff is the cleaner place to put the throttle.

Gate the routing decision on confidence#

This is the part that makes Jev worth using over an LLM classifier. Every Choice and Score answer carries confidence, a number from 0 to 1 derived from how flat the probability distribution is. The answer tells you what; the confidence tells you whether to act on it. Three bands, and the thresholds scale with how expensive it is to be wrong.

<?php

declare(strict_types=1);

namespace App\Support\Triage;

use App\Models\Ticket;

final class TicketRouter
{
    /**
     * @param  array<string, array<string, mixed>>  $answers
     */
    public function route(Ticket $ticket, array $answers): void
    {
        $department = $answers['department'];
        $confidence = (float) $department['confidence'];
        $choice = Department::tryFrom($department['choice']);

        $ticket->recordTriage($answers);

        match (true) {
            // An option we no longer recognise — treat it as unknown, not as a crash.
            $choice === null => $ticket->sendToHumanTriage(),

            // Clear read. Assign it and move on.
            $confidence >= 0.90 => $ticket->assignTo($choice),

            // Reasonable read. Pre-fill the agent's dropdown, let them confirm.
            $confidence >= 0.50 => $ticket->suggest($choice),

            // The model is telling you it does not know.
            default => $ticket->sendToHumanTriage(),
        };

        // A second team with a real share of the probability gets a copy.
        foreach ($department['probabilities'] as $name => $probability) {
            if ($name !== $department['choice'] && $probability > 0.25) {
                $ticket->notifyTeam(Department::from($name));
            }
        }

        // Noul has no confidence — test distance from the middle instead.
        if ((float) $answers['is_blocked']['noul'] > 0.90) {
            $ticket->markUrgent();
        }

        // Score 3 is "Angry, threatening to leave or escalate".
        if ((float) $answers['frustration']['score'] >= 2.5) {
            $ticket->flagForSeniorAgent();
        }
    }
}

Pick the numbers by blast radius, not by gut feel. Tagging a ticket with a department is recoverable, so 0.90 is generous. Auto-closing a ticket as a duplicate, or firing a refund, deserves a far higher gate and probably a confirmation step on top. TypeSafe's own guidance is to start conservative and tune against your own data, which is exactly why the next step exists.

Store the probabilities for auditing#

Keep the whole distribution, not just the winner. Six weeks of stored probabilities tells you whether 0.90 was too strict, which pairs of departments the model keeps confusing, and whether a new option you added flattened everything. Store the versioned model ID too — jev-latest is an alias that moves when TypeSafe ships a release, and thresholds you tuned against one version aren't automatically right for the next.

Schema::table('tickets', function (Blueprint $table): void {
    $table->string('triage_department')->nullable();
    $table->decimal('triage_confidence', 4, 3)->nullable();
    $table->json('triage_probabilities')->nullable();
    $table->decimal('triage_frustration', 3, 2)->nullable();
    $table->string('triage_model')->nullable();   // e.g. jev-1.13.0
    $table->timestamp('triaged_at')->nullable();
});

Cast the JSON column so the distribution comes back as an array, and record it in one place:

/**
 * @return array<string, string>
 */
protected function casts(): array
{
    return [
        'triage_probabilities' => 'array',
        'triage_confidence' => 'float',
        'triage_frustration' => 'float',
        'triaged_at' => 'datetime',
    ];
}

The cost side is negligible — Jev bills $0.042 per million input tokens and output tokens are free, so a 600-token triage request is a fraction of a penny. Still worth tracking the usage block alongside the probabilities if you already run the pattern from tracking token usage and cost in Laravel.

Test the thresholds with Pest datasets#

The routing logic is ordinary PHP, so test it as such. Http::fake() with recorded response fixtures means the whole triage path is testable without an API key, and a Pest dataset covering the three confidence bands catches the off-by-one on a boundary before production does.

<?php

declare(strict_types=1);

use App\Models\Ticket;
use App\Support\Triage\Department;
use App\Support\Triage\TicketRouter;

function choiceAnswer(string $choice, float $confidence, array $probabilities): array
{
    return [
        'department' => [
            'type' => 'choice',
            'choice' => $choice,
            'confidence' => $confidence,
            'probabilities' => $probabilities,
        ],
        'is_blocked' => ['type' => 'noul', 'noul' => 0.12],
        'frustration' => [
            'type' => 'score',
            'score' => 0.8,
            'legend' => ['0' => 'Calm and factual', '1' => 'Mildly annoyed'],
            'probabilities' => ['0' => 0.2, '1' => 0.8],
        ],
    ];
}

it('routes by confidence band', function (float $confidence, string $expected): void {
    $ticket = Ticket::factory()->create();

    app(TicketRouter::class)->route(
        $ticket,
        choiceAnswer('billing', $confidence, ['billing' => 0.95, 'returns' => 0.05]),
    );

    expect($ticket->fresh()->triage_state)->toBe($expected);
})->with([
    'auto-assign' => [0.95, 'assigned'],
    'boundary auto-assign' => [0.90, 'assigned'],
    'suggest' => [0.70, 'suggested'],
    'boundary suggest' => [0.50, 'suggested'],
    'human' => [0.30, 'needs_human'],
]);

it('falls back to a human when the API is down', function (): void {
    Http::fake(['api.typesafe.ai/*' => Http::response(status: 529)]);

    $ticket = Ticket::factory()->create();
    (new TriageTicket($ticket))->failed(null);

    expect($ticket->fresh()->triage_state)->toBe('needs_human');
});

Capture two or three real responses from the TypeSafe playground and commit them as fixtures. The same discipline applies here as with any model-backed code path — faking agents in Pest covers the fixture-recording habit in more depth.

Gotchas and Edge Cases#

Near-duplicate options flatten confidence. Add Refunds next to Billing and Returns and watch every money-related ticket drop to 0.4. If two options could reasonably both be right for the same ticket, either merge them or go hierarchical — ask a coarse Choice first, then a second Choice scoped to the winning branch. TypeSafe's hierarchical classification cookbook runs a beam search over Choice probabilities for exactly this.

Always include an other option. Without one, the model is forced to spread probability across options that all fit badly, and you get a mid-confidence answer where you wanted a clear "none of these."

Confidence is not accuracy. A confident answer is a concentrated distribution, not a guaranteed-correct one. The stored probabilities are how you find out which confident answers were wrong.

jev-latest moves underneath you. If you've tuned thresholds hard, pin jev-1.13.0 and move deliberately.

64k context, 32k for state plus the longest question. Long email threads with quoted history will blow through that. Trim the quoted replies before you build the state, or summarise older messages into a field of their own.

Don't send PII you don't need. Jev isn't trained on customer requests, but the smallest state that answers the question is still the right state.

Wrapping Up#

Build the enum first, put the "not for" clauses in the descriptions, and wire the three confidence bands before you wire anything clever. Ship it in suggest-only mode for a fortnight, store every distribution, then raise or lower the 0.90 gate based on what the data says rather than what feels safe.

Once triage is routing, the obvious next step is screening what goes into and out of any LLM sitting behind it — agent guardrails and moderation in Laravel covers that side. And if triage jobs are now a meaningful share of your queue, monitoring Laravel queues with Horizon in production is worth setting up before the first backlog, not after.

FAQ#

How do I classify support tickets with AI in Laravel?

Dispatch a queued job when the ticket is created, send the ticket content as state to Jev's POST /v1/systemone endpoint along with a Choice question whose criteria keys match a PHP backed enum, and map the returned choice string back with Department::tryFrom(). The whole integration is a thin Http::withToken() wrapper — roughly thirty lines — because there's no official PHP SDK yet. Routing then becomes ordinary match (true) logic on the answer and its confidence.

What confidence threshold should I use for AI classification?

There isn't one number. Use three bands and scale them by the cost of being wrong: act automatically above roughly 0.9, suggest-and-confirm between 0.5 and 0.9, and route to a human below 0.5. A low-stakes action like tagging a ticket can sit at a lower gate than a destructive one like issuing a refund. Start conservative, store the probability distributions, and tune against your own data after a few weeks.

Is Jev cheaper than using an LLM for classification?

Substantially, yes. Jev charges $0.042 per million input tokens and output tokens are free, so a triage request of a few hundred tokens costs a tiny fraction of a penny — and because questions are evaluated in parallel against one state, asking five questions costs barely more than asking one. The bigger saving is usually latency and engineering time: you get a typed answer instead of a string that needs parsing and validating.

How do I route tickets to a human when the AI isn't sure?

Read the confidence on the Choice answer and treat anything below your low threshold as a routing decision in its own right, sending the ticket to a manual triage queue. Do the same on failure: set $tries on the job, give it a backoff, and implement failed() so a 429, a 529 or a timeout marks the ticket as needing human attention rather than silently dropping it. Never let an unavailable third-party API be the reason a customer's ticket goes unanswered.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.