temporal2mistral
Move Python code from the Temporal SDK to Mistral Workflows.
It rewrites what can be rewritten safely and gives you a report of the rest, file by file and line by line.
$ temporal2mistral check ./src # report only, writes nothing
$ temporal2mistral apply ./src --diff # show the rewrite
$ temporal2mistral apply ./src # rewrite in place (commit first)
Alpha. Tested against temporalio 1.29 and mistralai-workflows 3.15. Mistral Workflows is in public
preview, so its API can still move. This project is not affiliated with Temporal Technologies or Mistral AI.
Why not search and replace?
Mistral Workflows runs on Temporal, so most of the API maps one to one. A few things don't:
- Temporal sets activity timeouts and retries at each call
(
workflow.execute_activity(fn, x, start_to_close_timeout=...)). Mistral sets them once on the definition (@activity(start_to_close_timeout=...)) and you call the activity directly (await fn(x)). The call is usually in a different module from the definition, so the tool scans the whole project before rewriting anything. - Mistral builds a JSON schema from every signature. Parameters and return types must be annotated,
activities must be module-level
async deffunctions, query and update handlers must return something, and child workflows andcontinue_as_newtake a single pydantic model. - The defaults differ. A Mistral workflow is cancelled after 1 hour unless you set
execution_timeout(Temporal has no limit), and activities retry 3 times (Temporal retries forever).
Install
pip install temporal2mistral
pip install "temporal2mistral[mistral]" # with the Mistral SDK, for --import-check
Needs Python 3.12 or later, like the Mistral SDK. From a clone: pip install -e ".[test,mistral]".
Example
Before:
@activity.defn
def compose_greeting(input: ComposeGreetingInput) -> str:
activity.logger.info("Running activity with parameter %s" % input)
return f"{input.greeting}, {input.name}!"
@workflow.defn
class GreetingWorkflow:
@workflow.run
async def run(self, name: str) -> str:
workflow.logger.info("Running workflow with parameter %s" % name)
return await workflow.execute_activity(
compose_greeting,
ComposeGreetingInput("Hello", name),
start_to_close_timeout=timedelta(seconds=10),
)
After temporal2mistral apply:
def _compose_greeting_sync(input: ComposeGreetingInput) -> str:
temporal_activity.logger.info("Running activity with parameter %s" % input)
return f"{input.greeting}, {input.name}!"
@activity(start_to_close_timeout=timedelta(seconds=10), retry_policy_max_attempts=0)
async def compose_greeting(input: ComposeGreetingInput) -> str:
return await asyncio.to_thread(_compose_greeting_sync, input)
@workflow.define(name="GreetingWorkflow")
class GreetingWorkflow:
@workflow.entrypoint
async def run(self, name: str) -> str:
temporal_workflow.logger.info("Running workflow with parameter %s" % name)
return await compose_greeting(ComposeGreetingInput("Hello", name))
And temporal2mistral check hello.py prints:
hello.py
14 WARNING T2M031 Synchronous activity `compose_greeting` wrapped in an `async def` that runs it in a thread
-> The original body now runs in a thread through `asyncio.to_thread`, like Temporal's `activity_executor`.
14 INFO T2M020 start_to_close_timeout, retry_policy_max_attempts moved to `@activity` on `compose_greeting` (from hello.py:24)
14 INFO T2M026 `retry_policy_max_attempts=0` (unlimited, Temporal's default) set on `compose_greeting`
-> Remove it to use Mistral's default of 3 attempts, or rerun with --mistral-retry-defaults.
15 INFO T2M060 `activity.logger` is used through `temporal_activity` (Mistral activities run on Temporal)
19 WARNING T2M010 Workflow `GreetingWorkflow` had no execution timeout on Temporal; Mistral cancels it after 1 hour by default
-> Pass `execution_timeout=timedelta(...)` to `@workflow.define`, or rerun with `--execution-timeout 30d`.
23 INFO T2M060 `workflow.logger` is used through `temporal_workflow` (Mistral workflows run on Temporal)
-> Works under a Mistral worker; raises in the SDK's local mode (execute_workflow outside a worker).
1 file(s) converted out of 1 using temporalio (1 scanned): 0 error(s), 2 warning(s), 4 info(s).
The output is correct but the imports aren't sorted, so run ruff, black or isort on it afterwards.
What gets converted
| Temporal | Mistral |
|---|---|
@workflow.defn, @workflow.defn(name=..., sandboxed=False) |
@workflow.define(name=..., enforce_determinism=False). The name defaults to the class name, as on Temporal |
@workflow.run |
@workflow.entrypoint |
@workflow.signal, @workflow.query, @workflow.update (with name=, description=) |
@workflow.signal(), @workflow.query(), @workflow.update() |
workflow.wait_condition, sleep, now, uuid4, random, unsafe.imports_passed_through |
same names on mistralai.workflows.workflow |
workflow.unsafe.sandbox_unrestricted() |
workflow.unsafe.skip_determinism_enforcement() |
@activity.defn, @activity.defn(name=...) |
@activity(name=..., start_to_close_timeout=..., heartbeat_timeout=..., retry_policy_max_attempts=..., retry_policy_backoff_coefficient=...) |
workflow.execute_activity(fn, arg) / args=[a, b] |
await fn(arg) / await fn(a, b) |
workflow.start_activity(fn, arg) |
asyncio.create_task(fn(arg)) |
workflow.execute_local_activity(...) |
a regular activity call (reported) |
synchronous def activity |
async def that runs the original body with asyncio.to_thread (or a plain async def with --sync-activities async) |
activity.heartbeat(...) |
activity_heartbeat(...) |
workflow.execute_child_workflow(Child.run, x, id=..., execution_timeout=..., parent_close_policy=...) |
workflow.execute_workflow(Child, params=..., execution_id=..., execution_timeout=..., parent_close_policy=...) |
workflow.start_child_workflow(...) |
workflow.execute_workflow(..., wait=False) |
workflow.continue_as_new(x) |
workflow.continue_as_new(params) |
-> NoReturn / -> Never |
-> None |
| no return annotation on a handler or activity that returns nothing | -> None added |
any other workflow.X / activity.X (logger, info(), patched(), all_handlers_finished()...) |
temporal_workflow.X / temporal_activity.X, imported with from temporalio import workflow as temporal_workflow. This works because Mistral workers run on Temporal |
activity method of a class built without arguments, called with execute_activity_method |
module-level activity that gets the instance through Depends(MyActivities); the method stays in the class |
worker = Worker(client, workflows=[...], ...) + await worker.run(), or async with Worker(...) that waits until interrupted |
await run_worker([...]), and the Client.connect that only served the worker is removed |
Activity options
Timeouts and retries are collected from every call site in the project, including module-level
constants like RETRY = RetryPolicy(maximum_attempts=3). If two call sites disagree, the tool keeps
the most permissive value and tells you. If schedule_to_close_timeout is the only timeout, it becomes
start_to_close_timeout. Temporal's unlimited retries become retry_policy_max_attempts=0, unless you
pass --mistral-retry-defaults.
Workflow inputs
Child workflows and continue_as_new take one pydantic model on Mistral. If the target's entrypoint
already takes a single pydantic model, the argument is passed through. Otherwise the tool writes a small
model next to the caller:
class ComposeGreetingWorkflowParams(BaseModel):
"""Input of the `ComposeGreetingWorkflow` workflow (generated by temporal2mistral)."""
input: Any
...
return await workflow.execute_workflow(
ComposeGreetingWorkflow, params=ComposeGreetingWorkflowParams(input=ComposeGreetingInput("Hello", name))
)
The child still validates its input against its own annotations, so dataclasses keep working.
Workers
A worker becomes run_worker when it only runs until it is stopped, has no option that matters on
Mistral (interceptors, plugins, tuning...), and is the only user of a client created next to it by
Client.connect (that line is removed too, unless the client has a data_converter, plugins,
interceptors or runtime). Activities register themselves, and task_queue is dropped because
Mistral routes executions to deployments. Other workers are reported with T2M070.
What you still have to do
The tool reports these and leaves the code alone:
- Workers the tool cannot convert safely (see above). The report points at each one and gives you the
run_workercall. - Client code, the code that starts workflows and talks to them: see Migrating client code below.
- Class-based activities whose class needs arguments to be built, and activities defined inside a
function. Mistral activities are module-level functions: use
Depends(...)with a function that builds the shared client. @workflow.init, update validators, dynamic workflows and handlers, Nexus, asynchronous activity completion, and query or update handlers that return nothing.- Types with no JSON schema, such as plain classes that relied on a custom Temporal payload converter.
- Execution timeouts. Every workflow gets a warning until you set
execution_timeout, or you can pass--execution-timeout 30dto set it on all of them. - Workflows already running on your Temporal cluster. They can't be moved: let them finish there and start new executions on Mistral.
Migrating client code
The code that starts workflows and sends them signals, queries and updates moves from the Temporal client to the Mistral API client. The tool does not rewrite it, because client code is usually spread across an application (created at startup, used in request handlers...), and Mistral returns results as JSON values instead of instances of the workflow's return type. Check what your code does with them.
Instead, check reports every call with the exact Mistral code to write (T2M071). Take this
Temporal starter, starter.py:
from temporalio.client import Client
from workflows import GreetingWorkflow
async def main() -> None:
client = await Client.connect("localhost:7233")
handle = await client.start_workflow(GreetingWorkflow.run, "Louis", id="greet", task_queue="greetings")
await handle.signal(GreetingWorkflow.approve, "Ana")
status = await handle.query(GreetingWorkflow.status)
print(status, await handle.result())
temporal2mistral check prints:
starter.py
7 WARNING T2M071 Temporal client in use: starting, signalling and querying workflows goes through the Mistral API
-> Create `client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])` (from mistralai.client import Mistral). Each call to change is reported with its translation; see https://github.com/louiscauret/temporal2mistral#migrating-client-code
8 WARNING T2M071 `client.start_workflow(...)` goes through the Mistral API client
-> `handle = await client.workflows.execute_workflow_async(workflow_identifier="GreetingWorkflow", input={"name": "Louis"}, execution_id="greet")`; then `await handle.result()` becomes `(await client.workflows.wait_for_workflow_completion_async(handle.execution_id)).result`
9 WARNING T2M071 `handle.signal(...)` goes through the Mistral API client
-> `await client.workflows.executions.signal_workflow_execution_async(execution_id=handle.execution_id, name="approve", input={"approver": "Ana"})`
10 WARNING T2M071 `handle.query(...)` goes through the Mistral API client
-> `(await client.workflows.executions.query_workflow_execution_async(execution_id=handle.execution_id, name="status")).result`
Mistral, written from those hints:
import os
from mistralai.client import Mistral
async def main() -> None:
client = Mistral(api_key=os.environ["MISTRAL_API_KEY"])
handle = await client.workflows.execute_workflow_async(
workflow_identifier="GreetingWorkflow", input={"name": "Louis"}, execution_id="greet"
)
await client.workflows.executions.signal_workflow_execution_async(
execution_id=handle.execution_id, name="approve", input={"approver": "Ana"}
)
status = (
await client.workflows.executions.query_workflow_execution_async(
execution_id=handle.execution_id, name="status"
)
).result
final = await client.workflows.wait_for_workflow_completion_async(handle.execution_id)
print(status, final.result)
The correspondence:
| Temporal | Mistral |
|---|---|
client = await Client.connect(...) |
client = Mistral(api_key=os.environ["MISTRAL_API_KEY"]) |
await client.execute_workflow(W.run, x, id="i") |
await client.workflows.execute_workflow_and_wait_async(workflow_identifier="W", input={"param": x}, execution_id="i") (returns the result) |
handle = await client.start_workflow(W.run, x, id="i") |
handle = await client.workflows.execute_workflow_async(workflow_identifier="W", input={"param": x}, execution_id="i") |
handle.id |
handle.execution_id |
await handle.result() |
(await client.workflows.wait_for_workflow_completion_async(handle.execution_id)).result |
await handle.signal(W.approve, x) |
await client.workflows.executions.signal_workflow_execution_async(execution_id=handle.execution_id, name="approve", input={"param": x}) |
await handle.query(W.status) |
(await client.workflows.executions.query_workflow_execution_async(execution_id=handle.execution_id, name="status")).result |
await handle.execute_update(W.add, x) |
(await client.workflows.executions.update_workflow_execution_async(execution_id=handle.execution_id, name="add", input={"param": x})).result |
Three rules to get right:
- The workflow is named, not passed as a class:
workflow_identifieris the name given to@workflow.define(name=...), which the tool sets to the Temporal workflow name. inputis a dict of the parameters by name ({"name": "Louis"}forrun(self, name: str)). When the workflow, signal, query or update takes a single pydantic model, the input is that model:input=order.model_dump(mode="json").- Handlers are named too:
name="approve"is the method name, or thename=given to@workflow.signal(...),@workflow.query(...)or@workflow.update(...).
task_queue has no equivalent: Mistral routes executions to deployments.
Options
temporal2mistral check PATH... [--fail-on error|warning|never]
temporal2mistral apply PATH... [--diff | --out DIR]
--execution-timeout DURATION set execution_timeout on every workflow (30d, 12h, 90m...)
--sync-activities thread|async how to convert synchronous activities (default: thread)
--mistral-retry-defaults use Mistral's 3 attempts where Temporal retried forever
--import-check import every converted module with the Mistral SDK (see below)
--import-root DIR directory put on sys.path for --import-check
--exclude GLOB skip paths (repeatable)
--min-severity error|warning|info
--format text|json --report FILE writes the report to a file
check exits with status 1 when it finds errors, so you can run it in CI:
temporal2mistral check src --format json --report migration-report.json
--import-check
Some problems only show up when the code runs: forward references in input types, third-party types
without a schema, and so on. With --import-check, each converted module is imported in a subprocess,
from a converted copy of the project, with mistralai-workflows installed. Mistral validates
definitions at import time, so anything it rejects is reported as T2M090 with the SDK's own message,
in the module where the error happens (often a workflow module imported by a worker). If the original
module doesn't import either (a missing dependency, for example), it is skipped and reported as T2M091.
Be aware that this runs your project's top-level code.
Diagnostics
Errors mean the converted code won't work until someone fixes it. Warnings mean it works but may behave differently. Infos record what was changed.
| Rule | Severity | Meaning |
|---|---|---|
| T2M000 | error | File could not be parsed |
| T2M001 | error | Dynamic workflow, activity or handler is not supported |
| T2M002 | error | Missing parameter type annotation (required by Mistral) |
| T2M003 | info | Added -> None return annotation (required by Mistral) |
| T2M004 | error | Missing return type annotation (required by Mistral) |
| T2M005 | warning | NoReturn/Never return annotation replaced by None |
| T2M006 | error | Type has no JSON schema (plain class needing a custom converter) |
| T2M010 | warning | Workflow execution timeout now defaults to 1 hour |
| T2M011 | warning | @workflow.defn option has no Mistral equivalent and was dropped |
| T2M012 | error | @workflow.init is not supported by Mistral |
| T2M013 | error | Update validators are not supported by Mistral |
| T2M014 | warning | Handler option has no Mistral equivalent and was dropped |
| T2M015 | error | Query and update handlers must return a value in Mistral |
| T2M020 | info | Activity call-site options moved to the @activity decorator |
| T2M021 | warning | Call sites disagree on activity options; the most permissive value was kept |
| T2M022 | warning | Activity option is not a literal and could not be moved |
| T2M023 | warning | Activity call option has no Mistral equivalent and was dropped |
| T2M024 | warning | RetryPolicy field has no Mistral equivalent and was dropped |
| T2M025 | info | No call site found for activity; Mistral defaults apply |
| T2M026 | info | Unlimited retries kept (Temporal default) instead of Mistral's 3 attempts |
| T2M030 | error | Class-based (method) activities are not supported by Mistral |
| T2M031 | warning | Synchronous activity made asynchronous |
| T2M032 | error | Activity call target could not be resolved to an activity definition |
| T2M033 | warning | start_activity converted to asyncio.create_task |
| T2M034 | info | Local activity converted to a regular activity |
| T2M035 | warning | Activity chosen at runtime: call converted, options not moved |
| T2M036 | error | Activities defined inside a function are not supported by Mistral |
| T2M037 | info | Class-based activity converted to a module-level function using Depends |
| T2M040 | info | Child workflow call converted to workflow.execute_workflow |
| T2M041 | error | Child workflow target could not be resolved |
| T2M042 | warning | Child workflow option has no Mistral equivalent and was dropped |
| T2M043 | warning | Optional argument omitted with a non-literal default; check the params model |
| T2M050 | error | continue_as_new option is not supported by Mistral |
| T2M060 | info | Temporal API kept through a direct temporalio import |
| T2M061 | warning | Temporal API kept, but Mistral offers a dedicated equivalent |
| T2M062 | error | Temporal API has no Mistral support (Nexus, async completion, ...) |
| T2M070 | warning | Temporal Worker must be replaced by run_worker |
| T2M071 | warning | Temporal client calls must be replaced by the Mistral API client |
| T2M072 | info | Temporal Worker replaced by run_worker |
| T2M090 | error | The Mistral SDK rejects the converted module at import time |
| T2M091 | info | Module not import-checked: the original does not import in this environment |
Tested on temporalio/samples-python
I test against Temporal's official Python samples
(commit b0e4d84, 9 October 2026): 734 Python files outside tests/.
| Modules converted (workflows, activities, workers) | 259 |
| Converted modules that don't compile | 0 |
| Modules importable here (dependencies installed) | 130 |
… accepted by mistralai-workflows 3.15 at import |
123 |
| … with no error diagnostic, accepted by the SDK | 102 / 102 |
| … rejected by the SDK with no error diagnostic | 0 |
The other 129 modules need optional integrations that weren't installed (OpenAI Agents, LangGraph,
Strands, Google ADK, boto3...). They were converted but not import-checked. All 7 rejected modules
already have error diagnostics: one uses a plain class as input (T2M006), one has an update handler
that returns None (T2M015), and five have recursive or forward-referenced input types and were
already flagged for @workflow.init (T2M012) or a dynamic activity call (T2M032). For those,
--import-check adds the SDK's exact message (T2M090).
To reproduce: python scripts/validate_corpus.py path/to/samples-python --out /tmp/converted.
The full list of errors on the samples is in docs/samples-python-errors.txt.
More tests go beyond imports. tests/test_sdk_validation.py runs a converted workflow (child
workflow, sync and async activities, retry policy, signal, query and update) in the Mistral SDK's local
mode. tests/test_e2e_temporal.py runs a converted workflow, with a class-based activity, on Temporal's test
server through the Mistral SDK's test worker. That one downloads the test server, so it runs in CI.
Development
pip install -e ".[test,mistral]" ruff
pytest # the SDK tests run when mistralai-workflows is installed
T2M_SAMPLES=/path/to/samples-python pytest
ruff check . && ruff format src tests scripts
CONTRIBUTING.md explains how the code is organised and the style rules.
Roadmap
- Optionally convert input dataclasses to pydantic models.
- Convert class-based activities whose class takes arguments, when the worker builds it in an obvious way.
License
Metadata
Release files for temporal2mistral 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| temporal2mistral-0.1.0.tar.gz | 62.3 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| temporal2mistral-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 118.9 kB
Release files / temporal2mistral-0.1.0.tar.gz
| Download URL | temporal2mistral-0.1.0.tar.gz |
|---|---|
| Size | 62.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e184cb62a31ef64b640e9502671dd6e41a6f8153166760f6c77a20116000f261
|
|
BLAKE2b-256 checksum How to use checksums |
a009d47dd71415992dacac1a0e779a02b1d348ae6e1e42b5a2b76527dcf85ccd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency logRelease files / temporal2mistral-0.1.0-py3-none-any.whl
| Download URL | temporal2mistral-0.1.0-py3-none-any.whl |
|---|---|
| Size | 56.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2fc1c71c056775a77538f488ae1f21892297bab45669427d3b3d37549bd7d70a
|
|
BLAKE2b-256 checksum How to use checksums |
38c37d96c3964f4dd4e9a9d6dee14183ae7a6d56e97b5ec70b59e78e41984429
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency logSecurity for temporal2mistral 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for temporal2mistral-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.