{"slug": "detecting-spam-and-auto-replies-with-jev-and-the-laravel-ai-sdk", "title": "Detecting spam and auto-replies with Jev and the Laravel AI SDK", "summary": "Taylor Otwell announced that Jev support landed in the 1.x branch of the Laravel AI SDK, and the There There helpdesk team began using it the same day for spam detection. Jev, made by TypeSafe, is a \"System One\" model that returns calibrated probabilities instead of generated text, using three question types — Noul (yes/no), Choice (one option from a named set), and Score (rating against described levels) — and is configured in config/ai.php with a TYPESAFE_API_KEY. The approach targets helpdesk mail that isn't from customers, such as out-of-office replies and bounced messages.", "body_md": "# Detecting spam and auto-replies with Jev and the Laravel AI SDK original\n\nYesterday [Taylor announced](https://x.com/taylorotwell/status/2100700952923713641) that Jev support landed in the 1.x branch of the Laravel AI SDK. We started using it that same day for spam detection in [There There](https://there-there.app). Let's take a look at what Jev is and how you can use it.\n\n## How Jev is different from an LLM\n\n[Jev](https://docs.typesafe.ai) is made by TypeSafe. An LLM generates text: you ask it something, it writes an answer, and if you want structured data back you have to ask for it and hope. Jev doesn't generate anything. You give it some state and a question, and it gives you back a number.\n\nTypeSafe calls these System One models. They read natural language like an LLM does, but instead of writing a reply they pick between answers that you define up front. The probabilities are calibrated, which means they're trained against real outcomes, so across a batch of answers a 0.9 should be right about nine times out of ten.\n\nIn practice that means no prose to parse, and no prompt asking the model to please respond with valid JSON.\n\nThe Syntax folks made a video explaining it:\n\n## Noul, Choice and Score\n\nYou define what the answers can be using one of three question types.\n\nA Noul is a yes or no question. The answer is a single number: the probability that the answer is yes. Here's how you ask one:\n\n``` php\nuse Laravel\\Ai\\Classification;\nuse Laravel\\Ai\\Classification\\Boolean;\n\n$result = Classification::of('I have asked three times now. Can I please talk to a real person?')\n    ->question('urgent', new Boolean('Does this request need an immediate response?'))\n    ->classify();\n\n$result['urgent']->probability;            // 0.94\n$result['urgent']->isTrue(threshold: 0.8); // true\n```\n\nNotice that you get back `0.94` instead of `true`. You decide where the cutoff is, which means you can set a different one per question.\n\nA Choice picks one option out of a set that you name. Here's an example:\n\n``` php\nuse Laravel\\Ai\\Classification\\Choice;\n\n$result = Classification::of('My card was charged twice for order A-104. Please refund the duplicate.')\n    ->question('department', new Choice('Which team should handle this request?', [\n        'billing' => 'Payments, invoices, and refunds',\n        'technical' => 'Bugs, outages, and integrations',\n        'sales' => 'Pricing, plans, and upgrades',\n    ]))\n    ->classify();\n\n$result['department']->choice;                   // 'billing'\n$result['department']->probabilityOf('billing'); // 0.87\n$result['department']->confidence;               // 0.82\n```\n\nNext to the option it picked, you also get a probability for every option, and a confidence score that tells you how concentrated those probabilities were.\n\nA Score rates something against levels that you describe yourself. You could ask how frustrated a customer is, where 0 is calm, 1 is frustrated and 2 is very angry. The answer can land between two levels, so a 1.4 is a perfectly good answer.\n\nThe state doesn't have to be a string. When a decision depends on more than one thing, you can pass an array so every part has a name:\n\n``` js\nClassification::of([\n    'subject' => 'Duplicate charge',\n    'message' => 'My card was charged twice for order A-104.',\n    'order' => ['id' => 'A-104', 'charges' => [49, 49]],\n    'refund_policy' => 'Duplicate charges are eligible for a refund.',\n])->question('refund_due', new Boolean('The policy entitles this customer to a refund.'))\n    ->classify();\n```\n\nThat's still one state, even though it holds a message, an order and a policy.\n\nYou configure Jev like any other provider in `config/ai.php`, with a `TYPESAFE_API_KEY` in your env file.\n\n## The problem we wanted to solve\n\nYou might have noticed that we [launched There There](https://x.com/freekmurze/status/2100549614248308805) yesterday as well, our new helpdesk. A lot of the mail that arrives in a helpdesk isn't from a customer. Out-of-office replies, bounces, subscription confirmations, DMARC reports. You don't want those in your inbox, and you don't want to pay an LLM to write a title and a summary for each one.\n\nSome of that mail says what it is in the headers. `Auto-Submitted`, an empty return path, a sender called mailer-daemon. Checking those costs nothing, so we do that first.\n\nPlenty of mail servers don't set those headers. For those we had a list of subject prefixes: `Automatische Antwort`, `Réponse automatique`, `Out of office`, in fifteen languages. Nine more for bounces. On top of that, rules so that a customer asking a question about auto-replies didn't get treated as one.\n\nEvery time we found mail that the list missed, we added another string to it.\n\n## Replacing the list\n\nThe header checks still run first. Everything they can't answer goes to Jev.\n\nWe describe each question once, as a case on an enum that also carries its own threshold. That way adding a fourth question is a single case instead of an edit in four files.\n\n```\nenum InboundJudgement: string\n{\n    case IsAutoResponse = 'is_auto_response';\n    case IsBounce = 'is_bounce';\n    case IsSpam = 'is_spam';\n\n    public function question(): Boolean\n    {\n        return match ($this) {\n            self::IsAutoResponse => new Boolean(\n                'A system sent this mail on its own, rather than a person choosing to write to us.',\n                [\n                    'true' => 'Sent on a trigger with no human involved at send time: out-of-office\n                        notices, delivery reports, ticket acknowledgements, subscription\n                        confirmations, digests and alerts. Wording composed in advance still counts',\n                    'false' => 'A person sat down and sent this. Still false when a contact form or\n                        chat widget wrapped their words in a template and added lines such as Name,\n                        E-mail or Subject',\n                ],\n            ),\n            self::IsSpam => new Boolean(\n                'This mail is unsolicited bulk mail, a scam, or phishing rather than a genuine\n                    message from a customer.',\n                [\n                    'true' => 'Cold sales outreach, marketing blasts, scams, phishing, or anything\n                        the recipient never asked for',\n                    'false' => 'A real person writing about the product, their account, or their own\n                        support request, however brief or badly written',\n                ],\n            ),\n            // ...\n        };\n    }\n\n    public function threshold(): float\n    {\n        return match ($this) {\n            self::IsAutoResponse => 0.75,\n            self::IsBounce, self::IsSpam => 0.9,\n        };\n    }\n}\n```\n\nThose `true` and `false` descriptions are optional, but I'd recommend writing them. They do more work than the question above them.\n\nAll the questions the headers couldn't answer go out in one request. Jev reads the state once and answers them in parallel, and you only pay for input tokens, so asking three questions costs the same as asking one. Here's the action that does it:\n\n```\npublic function execute(Message $message, Ticket $ticket, Workspace $workspace): void\n{\n    $judgements = array_filter(\n        InboundJudgement::cases(),\n        fn (InboundJudgement $judgement) => ! $judgement->settledByHeaders($message),\n    );\n\n    if ($judgements === []) {\n        return;\n    }\n\n    try {\n        $response = Classification::of([\n            'subject' => $ticket->subject,\n            'from_name' => $message->author_name,\n            'from_email' => $message->author_email ?? $ticket->contact?->email,\n            'message' => Str::limit($message->body_text, 10_000),\n        ])\n            ->questions($this->questionsFor($judgements))\n            ->timeout(10)\n            ->classify();\n\n        $verdicts = $this->verdicts($judgements, $response);\n    } catch (Throwable $exception) {\n        Log::warning('Could not classify an inbound message.', [\n            'message_id' => $message->id,\n            'error' => $exception->getMessage(),\n        ]);\n\n        return;\n    }\n\n    $message->updateQuietly([...$verdicts, 'classification' => $response->answers]);\n}\n```\n\nThere are two things in there I'd suggest copying. The whole call sits in a try block that logs the problem and moves on, because a classification is a nice to have and it shouldn't be able to break the mail pipeline it's helping. And we truncate the message, because an inbound mail can be megabytes long and nothing past the first part of it changes what the mail is.\n\nTurning the answers into booleans is where each question's own threshold is applied. We store the raw probabilities next to them, so we can change a threshold later and see what it would have done:\n\n```\nprivate function verdicts(array $judgements, ClassificationResponse $response): array\n{\n    $verdicts = [];\n\n    foreach ($judgements as $judgement) {\n        $answer = $response->answer($judgement->value);\n\n        $verdicts[$judgement->value] = $answer->isTrue($judgement->threshold());\n    }\n\n    return $verdicts;\n}\n```\n\nThose verdicts are stored on the message. When a customer builds a workflow in There There with an \"Is spam\" condition, checking that condition reads a single column and doesn't call Jev at all.\n\n## In closing\n\nI like that Jev does one small thing. It gives you a number and leaves the rest of the decisions in your own code, where you can read them and write tests for them.\n\nIt's also fast and cheap enough that you don't really have to think about it. Classifying a mail with three questions at once takes 639ms, and we get around 48 per second when we run them in parallel. We didn't spend any time tuning that, so I'm sure you could get more out of it, but for what we're doing it's fast enough. Jev costs $0.042 per million input tokens and output tokens are free, which for us comes down to four hundredths of a cent per mail, or about 36 cents a month.\n\nWe have a list of other places where we want to use this in There There, and in our other products. Expect more Jev powered features soon.\n\nIf you want to read more, there are the [TypeSafe docs](https://docs.typesafe.ai) and the [Laravel AI SDK](https://github.com/laravel/ai). And if you'd like to see the spam detection at work, you can try [There There](https://there-there.app).", "url": "https://wpnews.pro/news/detecting-spam-and-auto-replies-with-jev-and-the-laravel-ai-sdk", "canonical_source": "https://freek.dev/3194-detecting-spam-and-auto-replies-with-jev-and-the-laravel-ai-sdk", "published_at": "2026-09-18 16:00:05+00:00", "updated_at": "2026-09-18 16:23:48.692928+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools", "ai-products", "machine-learning"], "entities": ["Laravel AI SDK", "Jev", "TypeSafe", "Taylor Otwell", "There There", "Noul", "Choice", "Score"], "alternates": {"html": "https://wpnews.pro/news/detecting-spam-and-auto-replies-with-jev-and-the-laravel-ai-sdk", "markdown": "https://wpnews.pro/news/detecting-spam-and-auto-replies-with-jev-and-the-laravel-ai-sdk.md", "text": "https://wpnews.pro/news/detecting-spam-and-auto-replies-with-jev-and-the-laravel-ai-sdk.txt", "jsonld": "https://wpnews.pro/news/detecting-spam-and-auto-replies-with-jev-and-the-laravel-ai-sdk.jsonld"}}