Blog

Jev in Temporal: an activity, a retry policy and a deterministic workflow

This guide shows how to route tickets with Jev, the decision model by TypeSafe AI, in a Temporal workflow written in Python. The Jev call lives in an activity, with a retry policy suited to its errors. The workflow branches on confidence and falls back to your current LLM.

By Étienne Lescot · 23 September 2026

TicketJevconfidence ≥ threshold? yes: right teamno, error: LLM Temporal · log of every decision

What you will build

A workflow that routes a support ticket to billing, technical or sales. It is the same scenario as our n8n guide, with Temporal’s guarantees on top.

  • An ask_jev activity that asks Jev two questions: which team, and is it urgent.
  • A retry policy that retries 429 and 529, but never 401 or 422.
  • A RouteTicket workflow that compares the confidence with the threshold it receives as input.
  • A fallback activity, ask_current_llm, that reuses your current LLM router.
  • A Temporal history that keeps the model version, the probabilities, the confidence and the path taken.

Why the Jev call must live in an activity

A Temporal workflow must be deterministic. Temporal records every event in a history. After a worker restart, it replays the workflow code from the start and checks that it makes the same decisions.

A network call breaks that rule. On replay it could return a different answer, fail, or take ten seconds. The workflow would then follow another path, and Temporal would raise a non-determinism error.

An activity solves this. It runs once per attempt, and its result is written to the history. On replay, Temporal reads Jev’s answer from the history instead of calling the API again.

  • Jev is called once per attempt, even if the workflow is replayed ten times.
  • Every decision is reproducible: Jev’s exact answer is in the history.

The Python SDK adds a sandbox. It re-imports the workflow file for every run and blocks non-deterministic calls. The imports_passed_through block imports typesafe_sdk and anthropic once, outside the sandbox. Only the activities use them.

Before you start

  • Python 3.10 or later, and the Temporal CLI for a local development server.
  • A Jev API key, created in the TypeSafe console, in the TYPESAFE_API_KEY variable. Jev sign-ups are temporarily paused, so check your access.
  • The key for your current LLM. The example uses Claude Haiku 4.5 through ANTHROPIC_API_KEY. Swap in the model you already use.
pip install temporalio typesafe-sdk anthropic
temporal server start-dev

The full code

The file is under 80 lines: activities, workflow and worker. Copy it as is, then read the next sections to adapt it.

import asyncio
from dataclasses import dataclass
from datetime import timedelta

from temporalio import activity, workflow
from temporalio.client import Client
from temporalio.common import RetryPolicy
from temporalio.exceptions import ActivityError
from temporalio.worker import Worker

with workflow.unsafe.imports_passed_through():
    from anthropic import AsyncAnthropic
    from typesafe_sdk import AsyncTypeSafeClient, Choice, Noul, RetryPolicy as SdkRetryPolicy

JEV_MODEL = "jev-1.13.0"  # pinned: a threshold is calibrated for one model version
TEAMS = {"billing": "Payments, invoices, refunds", "technical": "Bugs, outages, integrations",
         "sales": "Pricing, quotes, new accounts"}
QUESTIONS = {
    "department": Choice(instructions="Which team should handle this ticket?", criteria=TEAMS),
    "urgent": Noul(instructions="Does the message express urgency?")}
PROMPT = "Which team should handle this ticket: billing, technical or sales? One word only.\n\n"
JEV_RETRY = RetryPolicy(  # 429 and 529: retried after 1 s, 2 s, 4 s, 8 s. 401 and 422: never.
    initial_interval=timedelta(seconds=1), backoff_coefficient=2.0, maximum_attempts=5,
    non_retryable_error_types=["TypeSafeAuthenticationError", "TypeSafeUnprocessableEntityError"])

@dataclass
class Ticket:
    text: str
    threshold: float = 0.6  # workflow input: every run records the threshold it used
    shadow: bool = True  # start here: Jev is logged, your current LLM still decides

class TicketActivities:
    def __init__(self):  # SDK retries off: Temporal owns them, and shows each attempt
        self.jev = AsyncTypeSafeClient(model=JEV_MODEL, retry=SdkRetryPolicy(max_retries=0))
        self.llm = AsyncAnthropic(max_retries=0)

    @activity.defn
    async def ask_jev(self, text: str) -> dict:
        res = await self.jev.system_one(state=text, questions=QUESTIONS)
        return {"model": res.model, **res.choices["department"].model_dump(),
                "urgent": res.nouls["urgent"].noul}

    @activity.defn
    async def ask_current_llm(self, text: str) -> str:  # your current LLM router, unchanged
        msg = await self.llm.messages.create(model="claude-haiku-4-5", max_tokens=256,
                                             messages=[{"role": "user", "content": PROMPT + text}])
        return msg.content[0].text.strip().lower()  # billing, technical or sales

@workflow.defn
class RouteTicket:
    @workflow.run
    async def run(self, ticket: Ticket) -> dict:
        jev, error = None, None
        try:
            jev = await workflow.execute_activity_method(
                TicketActivities.ask_jev, ticket.text, retry_policy=JEV_RETRY,
                start_to_close_timeout=timedelta(seconds=10))
        except ActivityError as err:  # retries spent, or a non-retryable error
            error = str(err.cause)
        if jev and not ticket.shadow and jev["confidence"] >= ticket.threshold:
            department, decided_by = jev["choice"], "jev"
        else:
            department = await workflow.execute_activity_method(
                TicketActivities.ask_current_llm, ticket.text,
                schedule_to_close_timeout=timedelta(minutes=2))
            decided_by = "llm"
        return {"department": department, "decided_by": decided_by, "jev": jev, "error": error}

async def main():
    client = await Client.connect("localhost:7233")
    acts = TicketActivities()
    async with Worker(client, task_queue="tickets", workflows=[RouteTicket],
                      activities=[acts.ask_jev, acts.ask_current_llm]):
        ticket = Ticket("Hi, my September invoice shows up twice. Can you refund the duplicate?")
        print(await client.execute_workflow(RouteTicket.run, ticket, id="ticket-1042",
                                            task_queue="tickets"))

if __name__ == "__main__":
    asyncio.run(main())

1. The activity that calls Jev

The client pins the model version. AsyncTypeSafeClient(model="jev-1.13.0") sets the model for every call. The jev-latest alias moves with each release, and your thresholds move with it.

The activities are methods of a class. The worker creates the clients once, and every execution shares them.

SDK retries are turned off. RetryPolicy(max_retries=0) leaves Temporal in sole charge of retries. Otherwise three SDK attempts times five Temporal attempts would make fifteen calls.

2. A retry policy suited to 429 and 529

Jev returns 429 above its rate limit, and 529 when it is overloaded. TypeSafe recommends retrying both with growing delays. JEV_RETRY does exactly that.

  • initial_interval and backoff_coefficient: waits of 1, 2, 4, then 8 seconds.
  • maximum_attempts=5: five calls at most, then the workflow moves to the fallback.
  • start_to_close_timeout: 10 seconds per attempt. A stuck call counts as a failure, and it is retried.
  • non_retryable_error_types: an invalid key (401) or a malformed question (422) will not fix itself.

That list holds Python class names. When an activity raises an ordinary exception, Temporal uses its class name as the error type. We checked it: 401 and 422 lead to a single call, 429 and 529 lead to five.

Want to honour the Retry-After header? Catch TypeSafeRateLimitError, read retry_after_ms, then raise an ApplicationError with next_retry_delay.

3. The deterministic workflow that branches on confidence

The workflow only reads its input and activity results. It does not look at the clock, an environment variable or a file. That is what makes it replayable.

The threshold and the shadow flag arrive in the input, the Ticket class. If the threshold were a constant and you changed it while a workflow was running, its replay could take the other branch. Temporal would flag that as a non-determinism error.

With the threshold in the input, each run keeps its own. The history also records the threshold applied, ticket by ticket.

If the Jev activity still fails after its attempts, the workflow receives an ActivityError. It records the cause and moves to the fallback. The ticket gets routed anyway.

4. The fallback activity

ask_current_llm reuses your current LLM call, moved into an activity. It only runs when Jev is unsure, fails, or shadow mode is on. In every other case your LLM is not called.

Its two-minute schedule_to_close_timeout caps its retries. If your LLM fails too, the workflow fails visibly in the UI instead of routing the ticket at random.

5. Start in shadow mode

Start with shadow=True, the default. Jev answers on every ticket and its answer is recorded, but your LLM decides. Once you are happy with how often they agree, start new workflows with shadow=False. Workflows already running are not affected.

6. The decision log

The Temporal history doubles as the log. It holds the input, every Jev attempt, its answer and the final result. Here is the result for a billing ticket handled by Jev, with the sample values from the docs:

{"decided_by": "jev", "department": "billing", "error": null, "jev": {"choice": "billing", "confidence": 0.81, "model": "jev-1.13.0", "probabilities": {"billing": 0.88, "sales": 0.0, "technical": 0.12}, "type": "choice", "urgent": 0.12}}

Note that Temporal deletes the history of closed workflows after the namespace retention period. For an audit spanning months, copy this result into your own store.

How we tested this code

We ran this file as is, with temporalio 1.33.0 and typesafe-sdk 0.7.1. The test uses WorkflowEnvironment.start_time_skipping(), which skips the waits between attempts. Jev’s calls went to a fake API that returned the responses documented by TypeSafe. The LLM was replaced by a test double.

  • Confidence of 0.81: Jev decides, one attempt, no LLM call.
  • Confidence of 0.10: the fallback picks the team.
  • Repeated 429 and 529 errors: five attempts, then the fallback.
  • 401 and 422 errors: a single attempt, then the fallback.
  • Threshold of 0.05 passed as input: Jev decides at 0.10.
  • Shadow mode: the LLM decides, Jev’s answer stays in the result.

Each history was then replayed with Replayer, with no non-determinism error. Do the same in your CI before every deployment. We also ran the Jev part of this code against the real API on 23/09/2026: jev-1.13.0 answered billing, confidence 1, in 374 ms from France, for 365 input tokens.

Choosing the right threshold

You cannot guess a threshold. You measure it on your data, in your language. Start one workflow per past ticket in shadow mode, then compare, for each threshold, the share Jev handles and its error rate. The n8n guide walks through the method.

Jev is trained on English first. TypeSafe recommends testing on your own content before using it in another language.

Before production

  • Data: Jev is hosted in the United States. Pseudonymise personal data before sending it, and have your DPO approve the transfer. The Temporal history holds the ticket text too.
  • Version: keep jev-1.13.0 pinned. Recalibrate before moving to the next one.
  • IDs: use the ticket ID as the workflow ID. Temporal then refuses to route the same ticket twice in parallel.
  • Sensitive decisions: high confidence is not authorisation. Any outcome unfavourable to a person must go through a human.

Want to see Jev decide before writing any code? Try the demo with your own sample tickets.

Jev and TypeSafe are trademarks of TypeSafe AI, Inc. This article is independent and not affiliated with TypeSafe AI.