Cleat Python SDK
Python SDK for the Cleat durable execution framework. Write workflows in Python, compile to WASM, and run on the Cleat engine.
from cleat_sdk import HostCalls, cleat_entry
@cleat_entry
def my_workflow(h: HostCalls, name: str) -> str:
resp = h.cleat_call("greeter", "Greet", {"name": name})
return resp
Installation
pip install cleat-sdk
Or from source:
cd python-sdk/
pip install -e .
Quick start
Define a workflow decorated with @cleat_entry. The framework injects a HostCalls instance as the first argument; additional arguments are deserialised from JSON input automatically.
from dataclasses import dataclass
from cleat_sdk import HostCalls, cleat_entry
@dataclass
class GreetingRequest:
name: str
language: str = "en"
@cleat_entry
def hello_workflow(h: HostCalls, request: GreetingRequest) -> str:
h.cleat_log(f"Hello workflow started for {request.name}")
response = h.cleat_call(
"greeter", "Greet",
{"name": request.name, "language": request.language}
)
h.cleat_log(f"Got response: {response}")
return response
The decorator generates a WASM export wrapper conforming to the Cleat ABI ((args_ptr, args_len, out_ptr, max_out_len) -> i64). On replay, completed calls return cached results instead of re-executing.
HostCalls overview
The HostCalls class binds 48 WASM host function imports, grouped by category below.
The number is derived, not counted by hand -- see "Deriving the surface" at the end
of this file, because it was 36 here for long enough to be copied into three more
places.
Workflow Identity
current_workflow_id() -> str-- the current workflow's unique IDcurrent_run_id() -> str-- the current run's unique ID
Time & Random
now() -> int-- wall-clock time in ms since epochrandom() -> int-- deterministic random value (same on replay)version() -> int-- workflow definition versionmin_version() -> int-- minimum supported version
Durable Execution
cleat_call(service, operation, request) -> str-- recorded API callcleat_call_typed(service, operation, request, result_type) -> T-- typed variant with automatic JSON deserialisationcleat_call_with_retry(service, operation, request, retry_policy) -> str-- server-side retrycleat_call_with_heartbeat(service, operation, request, interval_ms, progress_cb) -> str-- long-running call with progress updatescleat_sleep(duration_ms) -> None-- suspend for a duration (survives restarts)cleat_log(message) -> None-- emit a log messagecleat_fetch(url, method, headers, body) -> (body, status)-- durable HTTP fetch via hostcleat_send(service, operation, request) -> None-- fire-and-forget (no response)schedule_invoke(service, operation, request, delay_ms) -> None-- delayed one-shot invocation
Signals & Events
await_signals(signal_names, timeout_ms) -> SignalResult-- wait for external signalspoll_signal(name) -> (payload, found)-- non-blocking signal checkpoll_cancellation() -> (cancelled, reason)-- check for cancellation
Child Workflows
child_workflow(name, input) -> str-- start child workflow, returns run IDawait_child(run_id) -> str-- await a single child, returns result JSONawait_all_children(run_ids) -> list[ChildResult]-- await multiple children concurrently
State
set_query_state(key, value) -> None-- set queryable state key-value pair
Promises
create_promise(name, ttl_ms=None) -> str-- create a durable promise, returns promise IDawait_promise(promise_id, timeout_ms) -> PromiseResult-- await a promise with timeoutresolve_promise(promise_id, value) -> None-- resolve a promise from within the workflowreject_promise(promise_id, error) -> None-- reject a promise from within the workflow
Update Handlers
register_update_handler(name, handler, validator=None) -> None-- register update handler (bi-directional RPC)
There is no register_query_handler: it was removed 2026-08-09 because
nothing ever routed an external query to a registered handler -- it worked
only inside this SDK's own test harness. Use set_query_state to publish
state that any caller can read via GET /api/workflows/:id/query?key=X.
Lifecycle
cleat_defer(description) -> str-- register cleanup to run on exit, returns defer IDcontinue_as_new(input) -> None-- start a fresh run with new inputrun_detached(name, input_json) -> None-- start a workflow that outlives this one
Plugin Calls
plugin_call(plugin_name, function_name, input) -> str-- call a host plugin function
cleat_log vs print/stdout
cleat_log() writes to the workflow event history -- it is deterministic, recorded, and replayed. Messages appear in the workflow's event log and are visible through the Cleat monitoring UI, even when replaying from history.
print() / stdout writes to the host console -- it is non-deterministic and not replayed. During replay, print() output will only appear if the workflow code actually executes (e.g., cached results skip execution entirely).
@cleat_entry
def my_workflow(h: HostCalls, name: str) -> str:
# WRONG: stdout output is not deterministic, not visible in replay
print(f"Processing {name}")
# CORRECT: goes to the worker's logger, tagged with the workflow id, and
# suppressed on replay so a resumed run does not re-emit it.
# NOT recorded in event history -- see cleat#1308.
h.cleat_log(f"Processing {name}")
result = h.cleat_call("service", "Op", {"name": name})
# WRONG: stdout is not replayed
print(f"Result: {result}")
# CORRECT: cleat_log is replayed deterministically
h.cleat_log(f"Result: {result}")
return result
When to use each:
| Use Case | Method | Reason |
|---|---|---|
| Workflow-visible logging | cleat_log() |
Survives replay, recorded in history |
| Debugging during development | print() |
Immediate stdout output, no host call overhead |
| Structured observability | log_kv() |
Key-value logging, recorded in history |
| External logging pipeline | plugin_call("logger", "send", ...) |
Forward events to an external system |
Use cleat_log for messages that should appear in the workflow event history. Use print sparingly for development-time debugging only.
Examples
The examples/ directory contains ready-to-run workflows:
| File | Description |
|---|---|
hello_workflow.py |
Simple call-and-return workflow (see Quick start) |
child_workflow.py |
Parent workflow that starts and awaits a child |
saga_workflow.py |
Saga pattern with compensating transactions |
update_handler_workflow.py |
Workflow with an "approve" update handler and validator |
Synchronous Execution Model
All Cleat Python workflows are SYNCHRONOUS. The async/await pattern is NOT supported. Workflows execute as plain Python functions that may be suspended and resumed by the host runtime through the SuspendSentinel exception mechanism.
# CORRECT: synchronous code
@cleat_entry
def my_workflow(h: HostCalls, name: str) -> str:
h.cleat_log(f"Processing {name}")
result = h.cleat_call("service", "Op", {"name": name})
return result
# WRONG: async is not supported
# @cleat_entry
# async def my_workflow(h: HostCalls, name: str) -> str: ... # won't work
When a workflow calls cleat_sleep() or await_signals() on a fresh execution, the host suspends the workflow. On replay, the same calls return cached results without suspending. The user code never sees the suspend/resume cycle -- it is handled by the @cleat_entry wrapper.
Converting async Python to synchronous WASM code
When porting async Python code to Cleat, every async def / await pattern must be replaced with synchronous equivalents.
Before (async -- will not compile to WASM):
import asyncio
import aiohttp
async def fetch_data(url: str) -> dict:
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
return await resp.json()
async def process_order(order_id: str) -> dict:
data = await fetch_data(f"https://api.example.com/orders/{order_id}")
await asyncio.sleep(5)
return {"order_id": order_id, "data": data}
After (Cleat synchronous -- compiles to WASM):
from cleat_sdk import HostCalls, cleat_entry
import json
def fetch_data(h: HostCalls, url: str) -> dict:
response, status = h.cleat_fetch(url, "GET")
return json.loads(response)
@cleat_entry
def process_order(h: HostCalls, order_id: str) -> dict:
data = fetch_data(h, f"https://api.example.com/orders/{order_id}")
h.cleat_sleep(5000) # instead of asyncio.sleep(5)
return {"order_id": order_id, "data": data}
Key conversion rules:
| Async pattern | Cleat synchronous replacement |
|---|---|
async def fn() |
def fn() |
await call() |
call() (synchronous) |
asyncio.sleep(n) |
h.cleat_sleep(n * 1000) |
aiohttp.ClientSession() |
h.cleat_fetch() or h.cleat_call() |
asyncio.gather(*tasks) |
h.await_all_children(run_ids) or sequential calls |
asyncio.wait_for(coro, timeout) |
h.await_signals(names, timeout_ms) or h.await_promise(id, timeout_ms) |
async for (stream) |
h.plugin_call_streaming() (iterator-based) |
async with (context manager) |
Regular with statement |
All Cleat workflows are synchronous. The host handles suspension and resumption transparently through the SuspendSentinel mechanism.
Python Standard Library Compatibility
When compiled to WASM via componentize-py, only a subset of the Python standard library is available. The following tables document the compatibility status of commonly used modules.
Fully available
These modules work without restrictions in WASM:
| Module | Notes |
|---|---|
json |
JSON encoding/decoding (heavily used by the SDK) |
math |
Mathematical functions |
datetime |
Date and time types (basic usage) |
collections / collections.abc |
Container data types |
typing / typing_extensions |
Type annotations |
dataclasses |
Data class definitions (preferred for input/output types) |
hashlib |
Cryptographic hash functions |
functools |
Decorators and higher-order functions |
itertools |
Iterator tools |
enum |
Enumerations |
decimal |
Decimal arithmetic |
uuid |
UUID generation |
copy |
Shallow/deep copy |
heapq |
Heap queue algorithm |
bisect |
Array bisection algorithm |
base64 |
Base64 encoding/decoding |
struct |
Pack/unpack binary data (limited to basic types) |
abc |
Abstract base classes |
contextlib |
Context manager utilities |
Limited availability
These modules are partially available with documented restrictions:
| Module | Status | Restrictions |
|---|---|---|
os |
⚠️ Limited | os.environ.get(), os.name work. No file I/O (os.open, os.listdir), no os.fork, no os.pipe |
sys |
⚠️ Limited | sys.version, sys.platform, sys.getsizeof() work. No sys.stdin, sys.stdout, sys.stderr access |
re |
⚠️ Limited | Basic matching works. Backreferences and lookahead/lookbehind may fail in some builds |
pathlib |
⚠️ Limited | Path manipulation works. No real filesystem I/O (read_text, write_text, etc.) |
random |
⚠️ Limited | Basic PRNG works. Use h.random() for deterministic workflow randomness instead |
json |
✅ Fully available, see above | |
time |
⚠️ Limited | time.time() works. time.sleep() is not available (use h.cleat_sleep()) |
Not available
These modules will fail at import time or runtime in WASM:
| Module | Reason |
|---|---|
asyncio |
Async/await not supported in WASM CPython |
threading |
No thread support in WASM |
multiprocessing |
No process support |
socket |
No raw socket access |
subprocess |
No process spawning |
signal |
No OS signal handling |
select / selectors |
No I/O multiplexing |
mmap |
No memory mapping |
ctypes / cffi |
No native code interop |
ssl |
No TLS/SSL (use host's cleat_call instead) |
http.client / urllib |
No outbound HTTP (use host calls instead) |
requests |
Third-party HTTP library (use h.cleat_fetch() instead) |
xml / xmlrpc |
XML parsing may be unavailable; avoid |
tkinter |
No GUI support in WASM |
concurrent.futures |
Depends on threading |
asyncio-based libs |
Any library depending on asyncio (e.g., aiohttp) |
Making HTTP requests
Replace standard Python HTTP libraries with host-provided durable calls:
# INSTEAD OF: import requests; requests.get(url)
# USE:
response = h.cleat_fetch(url, "GET", {}, None)
# INSTEAD OF: import urllib.request; urllib.request.urlopen(url)
# USE:
response = h.cleat_call("http", "fetch", {"url": url, "method": "GET"})
The host calls are recorded in the workflow history and replayed deterministically, which standard HTTP libraries cannot provide.
No direct database access from WASM
WASM workflows cannot make direct database connections. Modules like sqlite3, psycopg2, pymongo, or any library that opens sockets or file descriptors will fail at import time or runtime in the WASM environment.
Data access must go through one of these deterministic alternatives:
cleat_callto a service -- Call a registered backend service that performs the database operation:
# INSTEAD OF: import psycopg2; conn.execute("SELECT ...")
# USE:
response = h.cleat_call("db-service", "query", {"sql": "SELECT * FROM orders"})
plugin_callto a host plugin -- Use a host-registered plugin that provides database access:
response = h.plugin_call("postgres", "query", {"sql": "SELECT * FROM orders"})
cleat_fetchto a REST API -- Call an external API that wraps database access:
response, status = h.cleat_fetch("https://api.example.com/orders", "GET")
All three approaches are recorded in the workflow event history and survive replay, which direct database connections cannot guarantee.
Unit Differences (Cleat vs. Other Frameworks)
Cleat uses milliseconds for all time-related host calls. This is important when porting workflows from other frameworks:
| Framework | Sleep Unit | Example |
|---|---|---|
| Cleat | milliseconds | cleat_sleep(5000) = 5 seconds |
| Temporal | Go: time.Duration, Java: Duration, Python: timedelta / seconds |
sleep(Duration.ofSeconds(5)) |
| DBOS | seconds | DBOS.sleepSeconds(5) |
| Restate | Duration / milliseconds (SDK-dependent) |
Duration.ofSeconds(5) |
Cleat's cleat_sleep(duration_ms) always takes milliseconds. The advance_time(ms) method in the test harness also uses milliseconds. This is consistent with the WASM host ABI which uses i64 milliseconds for all timing operations.
Conversion examples when porting from other frameworks:
# Temporal (Python): workflow.sleep(timedelta(seconds=5))
# Cleat:
h.cleat_sleep(5000) # 5 seconds in ms
# DBOS: DBOS.sleepSeconds(5)
# Cleat:
h.cleat_sleep(5_000)
# Restate (Java): Duration.ofSeconds(5)
# Cleat:
h.cleat_sleep(5_000)
# Helper pattern for readability:
def sleep_seconds(h: HostCalls, secs: int) -> None:
"""Sleep for the given number of seconds."""
h.cleat_sleep(secs * 1000)
sleep_seconds(h, 5) # 5 seconds
Virtual Objects (Entity Workflows)
The Virtual Object pattern models long-lived stateful entities that process signals over time. Use set_scope/get_scope/clear_scope on HostCalls to scope all state operations to a specific entity instance.
Complete entity workflow example
from cleat_sdk import HostCalls, cleat_entry
@cleat_entry
def counter_entity(h: HostCalls, instance_key: str) -> str:
# Scope all state operations to this entity instance
h.set_scope("counter", instance_key)
# Initialize or restore state
count = 0
if h.has_state("count"):
count = counter # an ordinary local: replay makes it durable
h.cleat_log(f"Counter {instance_key} starting at {count}")
# Process signals in a loop (long-lived entity)
while True:
# Wait for the next signal
result = h.await_signals(["increment", "reset", "get", "stop"], None)
if result.timed_out:
continue
name = result.name
payload = result.payload
if name == "increment":
count = h.incr_state("count", 1)
h.cleat_log(f"Counter {instance_key} incremented to {count}")
elif name == "reset":
counter = 0
count = 0
h.cleat_log(f"Counter {instance_key} reset to 0")
elif name == "get":
# Return current value via query state
h.set_query_state("value", str(count))
elif name == "stop":
h.cleat_log(f"Counter {instance_key} stopping at {count}")
return f'{{"final_count": {count}}}'
return f'{{"count": {count}}}'
This pattern:
- Uses
set_scope("counter", instance_key)to isolate state per instance - Loops on
await_signalsto stay alive across signals - Cleans up with
clear_scope()when switching instances or at the end - Uses
continue_as_new()periodically to compact history
Signals vs Update Handlers
Cleat provides two patterns for external interaction with running workflows:
Signal patterns (fire-and-forget)
Signals are one-way messages delivered to a workflow. The workflow polls or awaits them.
Fire-and-forget signal (signal_workflow):
# From another workflow:
h.signal_workflow(target_run_id, "increment", '{"delta": 1}')
Request-response with signals (send_signal_and_wait / reply_to_signal):
# Sender workflow:
response = h.send_signal_and_wait(
target_run_id, "approve_request",
'{"id": "req-42"}', timeout_ms=30000
)
# Receiver workflow (inside signal handler):
result = h.await_signals(["approve_request"], None)
# Parse correlation ID from payload and respond:
h.reply_to_signal(correlation_id, '{"approved": true}')
Non-blocking signal poll (poll_signal):
payload, found = h.poll_signal("update")
if found:
h.cleat_log(f"Received signal: {payload}")
Update handler pattern (bi-directional RPC)
Update handlers provide synchronous request-response for external clients:
@cleat_entry
def approval_workflow(h: HostCalls, input: str) -> str:
# Register an "approve" update handler with validation
def approve_handler(payload: str) -> str:
# Process the update
h.set_query_state("approved", payload)
return '{"status": "approved"}'
def approve_validator(payload: str) -> bool:
data = json.loads(payload)
return data.get("amount", 0) > 0
h.register_update_handler("approve", approve_handler, approve_validator)
# Workflow continues...
result = h.await_signals(["done"], 60000)
return '{"status": "completed"}'
When to use each
| Pattern | Method | Use Case |
|---|---|---|
| Fire-and-forget signal | signal_workflow |
Notifications, events without response needed |
| Request-response (signal) | send_signal_and_wait / reply_to_signal |
Cross-workflow RPC with a response |
| Non-blocking poll | poll_signal |
Periodic checks without suspending |
| Update handler | register_update_handler |
External client RPC with validation |
Key differences:
- Signals are recorded in event history and survive replay; update handlers execute during workflow init
- Update handlers support validators that run before the handler
- Signals support non-blocking polling (
poll_signal); update handlers are always registered upfront - Use signals for cross-workflow communication; use update handlers for external client interactions
Per-call timeout limitations
Per-call timeouts are under development and not yet enforced on the host side during WASM execution. The cleat_call_with_retry host import does not accept a timeout parameter.
Workaround: Use cleat_sleep + polling for timeout-aware patterns:
def call_with_timeout(
h: HostCalls, service: str, op: str, request: Any, timeout_ms: int
) -> str:
"""Call a service with a client-side timeout using polling."""
deadline = h.now() + timeout_ms
last_error = None
while h.now() < deadline:
try:
return h.cleat_call(service, op, request)
except RuntimeError as e:
last_error = e
h.cleat_sleep(1000) # poll interval
raise RuntimeError(f"Call timed out after {timeout_ms}ms: {last_error}")
Host-side timeout enforcement is on the roadmap.
all_handlers_finished absence
Cleat does not have an all_handlers_finished equivalent (found in Temporal for update/signal handler tracking). There is no built-in mechanism to wait until all in-flight update handlers have completed.
Alternative patterns:
- Manual handler counting -- Track handler invocations with state keys:
@cleat_entry
def tracked_workflow(h: HostCalls, input: str) -> str:
pending_handlers = 0
def handler(payload: str) -> str:
h.incr_state("pending_handlers", 1)
try:
result = h.cleat_call("service", "Op", payload)
return result
finally:
h.incr_state("pending_handlers", -1)
h.register_update_handler("process", handler)
# ... workflow continues ...
- Durable promises -- External callers signal completion via promises:
@cleat_entry
def promise_tracked_workflow(h: HostCalls, input: str) -> str:
promise_id = h.create_promise("all_handlers_done")
def handler(payload: str) -> str:
result = h.cleat_call("service", "Op", payload)
# Signal done via the promise
h.resolve_promise(promise_id, "done")
return result
h.register_update_handler("process", handler)
# Wait for the promise instead of all_handlers_finished
result = h.await_promise(promise_id, 30000)
return result.result
For coordinating multiple concurrent operations, use await_all_children with child workflows or await_signals with a quorum pattern instead.
Multi-export WASM modules (Python)
A single Python file can export multiple workflow entry points using @cleat_entry:
from cleat_sdk import HostCalls, cleat_entry
@cleat_entry
def place_order(h: HostCalls, input: str) -> str:
return h.cleat_call("orders", "Place", input)
@cleat_entry
def cancel_order(h: HostCalls, input: str) -> str:
return h.cleat_call("orders", "Cancel", input)
@cleat_entry
def get_order_status(h: HostCalls, input: str) -> str:
return h.cleat_call("orders", "Status", input)
When compiled to WASM, componentize-py generates a named export for each decorated function. The host dispatches invocations by matching the called workflow name to the export name. Each export wrapper handles ABI marshalling, suspend/resume, and error serialization independently.
WASM Compilation Pipeline
Compile Python workflows to WASM using componentize-py from the Bytecode Alliance, which translates Python bytecode into a WASM component conforming to the Cleat WIT world.
Prerequisites
Install the required tools:
# Install componentize-py (requires Python 3.10+)
pip install componentize-py>=0.12.0
# Install the Cleat Python SDK (ensures cleat_sdk is importable)
pip install cleat-sdk
# Or from source:
# cd python-sdk/ && pip install -e .
Verify the installation:
componentize-py --version
# Expected output: componentize-py 0.x.y
If you encounter command not found, ensure your Python Scripts/bin directory is on PATH:
# Unix/macOS:
export PATH="$HOME/.local/bin:$PATH"
# Windows:
# pip install --user will add to %APPDATA%\Python\Scripts
Configuration (pyproject.toml)
The componentize-py tool reads WIT configuration from pyproject.toml under the [tool.componentize-py] table. The canonical configuration for this SDK is:
[tool.componentize-py]
wit_path = "wit/"
world = "cleat-workflow"
wit_path-- directory containing the Cleat WIT files (.witfiles defining the WASM component interface)world-- the WIT world your workflow component implements (must match whatcomponentize-pyexpects)
If you are writing a workflow outside this repository, copy the wit/ directory from the SDK or install the SDK as a dependency so the WIT files are discoverable.
Compilation Command
The basic componentize-py invocation (modern syntax):
componentize-py componentize my_workflow.py \
--wit-path wit/ \
--world cleat-workflow \
-o my_workflow.wasm
Required flags:
| Flag | Purpose |
|---|---|
componentize <file> |
Subcommand; the Python file containing your @cleat_entry workflow function |
--wit-path <dir> |
Path to the directory with the Cleat WIT files (cleat.wit) |
--world <name> |
The WIT world to implement (e.g. cleat-workflow) |
-o <file> |
Output WASM file path |
The SDK directory must be on PYTHONPATH so cleat_sdk is importable:
export PYTHONPATH=/path/to/python-sdk:$PYTHONPATH
componentize-py componentize my_workflow.py --wit-path wit/ --world cleat-workflow -o my_workflow.wasm
The Cleat CLI provides a convenience wrapper that handles paths automatically:
cleat build --target python ./workflow.py
Handling Imports in WASM Context
When your workflow is compiled to WASM, componentize-py bundles a minimal CPython interpreter. Import behavior differs from native Python:
- Pure-Python imports -- Pure Python modules on the Python path work normally
- C extension modules -- Native code (
.so/.pyd) does not work in WASM - stdlib modules -- Only pure-Python stdlib modules are available (see compatibility section)
- Third-party packages -- Pure-Python packages (e.g.,
langchain-core) can work if they are on the Python path and do not depend on C extensions - Relative imports -- Use absolute imports within your workflow file; relative imports may not resolve correctly in the WASM context
Always test your imports by compiling and running a minimal workflow before adding complex dependencies.
Step-by-Step Example
Compile the hello_workflow.py example to WASM:
# 1. Install dependencies
pip install componentize-py>=0.12.0
pip install -e python-sdk/
# 2. Verify the WIT directory exists
ls python-sdk/wit/cleat.wit
# 3. Compile the workflow
cd python-sdk/
componentize-py componentize examples/hello_workflow.py \
--wit-path wit/ \
--world cleat-workflow \
-o hello_workflow.wasm
# 4. Verify the output
ls -lh hello_workflow.wasm
# Expected: hello_workflow.wasm (~5-15 MB for CPython + workflow code)
# 5. Inspect the WASM exports (requires wasm-tools or wasm-objdump)
wasm-tools dump hello_workflow.wasm | head -20
# Or:
wasm-objdump -x hello_workflow.wasm | grep Export
The output WASM file can be loaded by the Cleat worker runtime. The compiled component exports entry points that dispatch to your @cleat_entry-decorated functions.
Using the Makefile
A Makefile in the SDK root provides convenience targets:
cd python-sdk/
make wasm # Compile all example workflows to WASM
make test-wasm # Compile and run WASM-specific tests
make clean # Remove compiled WASM artifacts
See the Makefile for details.
Troubleshooting
| Error | Likely Cause | Solution |
|---|---|---|
componentize-py: command not found |
Not installed or not on PATH |
pip install componentize-py; verify PATH includes ~/.local/bin |
Error: No such file or directory: 'wit/...' |
WIT path is incorrect | Pass --wit-path pointing to the directory with cleat.wit |
ModuleNotFoundError: No module named 'cleat_sdk' |
SDK not on Python path | pip install -e python-sdk/ or set PYTHONPATH |
Error: Component model world 'cleat-workflow' not found |
WIT path does not contain the correct world | Verify WIT files; check the world name in pyproject.toml |
RuntimeError: async function |
Async code in workflow | Remove async/await; all Cleat workflows are synchronous |
Build FAILED (exit code 1) without clear error |
Try verbose mode | Add --verbose to see full componentize-py output |
| WASM binary > 25 MB | Expected for CPython-in-WASM | Normal; the runtime caches the binary after first load |
| WASM binary > 50 MB | Large or unnecessary imports | Check that imported modules are genuinely needed at runtime |
ImportError: ... at WASM runtime |
Module not bundled | Only pure-Python modules are available; C extensions will not work |
Requirements
- Python 3.10+ -- the SDK uses
typing.get_type_hintsandinspect.signaturefor parameter introspection - componentize-py >= 0.12.0 -- required for WASM compilation (installed via
pip install componentize-py) - Cleat CLI (
cleat build) -- for the full build pipeline (optional; usecomponentize-pydirectly as shown above) - WIT files -- the
wit/directory defining the Cleat component model interface
Optional dependencies for development:
pip install "cleat-sdk[dev]" # pytest, pytest-cov, ruff
API reference
The SDK exposes six main modules via cleat_sdk/:
| Module | Contents |
|---|---|
host_calls |
HostCalls, SuspendSentinel, RetryPolicy, SignalResult, ChildResult, PromiseResult |
entry |
cleat_entry, virtual_object decorators |
memory |
WASM linear memory helpers and bit-packing decoders |
types |
ChildWorkflow[T], Saga, SagaStep, TerminalError, DurableDefer |
test_harness |
CleatTestHarness — in-memory workflow test harness with mock host calls |
client |
CleatClient — REST client for programmatic workflow interaction |
plugins |
Plugins, BlobPutResult, BlobGetResult, AwaitEventResult, EvaluateFlagResult, ProduceResult, SendWebhookResult, TriggerIncidentResult, ResolveIncidentResult, SendMessageResult, AwaitWebhookResult |
cleat_entry(name=None)
Decorator that marks a function as a Cleat workflow entry point. The decorated function must accept HostCalls as its first parameter. Additional parameters are deserialised from the workflow input JSON by name. The decorator handles suspend propagation (SuspendSentinel exception) and error serialisation.
virtual_object(name=None)
Decorator similar to @cleat_entry but for entity workflows (virtual objects). Like cleat_entry, the decorated function must accept HostCalls as its first parameter. Sets _is_virtual_object = True for introspection by the build tooling.
HostCalls
Core class binding the 48 WASM host function imports (derived -- see "Deriving the
surface"). Each method handles the pointer+length string protocol and bit-packed i64 result decoding per the Cleat ABI. The host runtime guarantees deterministic replay — all side effects are recorded in the event history.
Result types
SignalResult(name, payload, timed_out)— returned byawait_signalsChildResult(run_id, result, error)— returned byawait_all_childrenPromiseResult(result, timed_out, rejected)— returned byawait_promise(rejectedisTrueif the promise was rejected)RetryPolicy(max_attempts, initial_interval_ms, backoff_coefficient, max_interval_ms, non_retryable_errors)— forcleat_call_with_retrySuspendSentinel— exception raised to signal workflow suspensionTerminalError— exception raised from a saga step to trigger immediate compensation (non-retryable)CleatCallError— raised when a durable call fails, withCleatCallTimeoutError,CleatCallTransientErrorandCleatCallPermanentErrorselected by the host's error code
A failed call raises; it does not come back as a response
call, call_with_retry, call_with_heartbeat, child_workflow,
child_workflow_with_options, plugin_call and plugin_call_streaming return
result<string, call-failure> in wit/cleat.wit, which the generated bindings lift into
"return the response, or raise". So a failure reaches you as a CleatCallError, and the
engine stopping your workflow reaches you as SuspendSentinel — neither can arrive as a
string, whatever the service on the other end returns.
This changed in IMPROVEMENT-PLAN 3.110. Before it, these returned a bare string, and both
cases arrived as ordinary successful responses: a failure as the error text, and a stop as
"". If your workflow inspected a response for an error marker, delete that check — the
"__CLEAT_ERROR__:" prefix the SDK looked for had no producer in the host and never appeared.
Saga
Saga.add_step(step_or_name, action=None, compensate=None)— register a step viaSagaStepinstance or closure-based callablesSaga.add_step_fn(name, action, compensate=None)— register a step via callables that receiveHostCalls(avoids closure-capture issues across WASM suspend/resume)Saga.execute(terminal_exceptions=None)— run all steps, compensating onTerminalErroror any type in terminal_exceptions
CleatClient(base_url, timeout)
External REST client for interacting with the Cleat host:
start_workflow(name, input, idempotency_key=None) -> str— start a workflow, returns run IDsend_signal(run_id, signal_name, payload) -> None— send a signal to a running workflowresolve_promise(promise_id, value) -> None— resolve a durable promiseget_query_state(run_id, key) -> Any— read queryable workflow stateget_workflow_status(run_id) -> dict— get workflow status, result, and timestamps
CleatTestHarness()
In-memory test harness for unit-testing workflows without a running host. Simulates the WASM execution environment in pure Python with a mock linear memory, providing deterministic test coverage comparable to Go's cleattest.TestEnv.
from cleat_sdk import HostCalls, cleat_entry, CleatTestHarness
@cleat_entry
def GreetWorkflow(h: HostCalls, name: str) -> str:
h.cleat_log(f"Hello {name}")
return h.cleat_call("greeter", "Greet", {"name": name})
def test_greet_workflow():
harness = CleatTestHarness()
harness.register_call_stub("greeter", "Greet", '{"result": "Hello Alice"}')
result = harness.run(GreetWorkflow, "Alice")
assert result == '{"result": "Hello Alice"}'
harness.assert_called("greeter", "Greet", times=1)
Key methods:
run(entry_fn, input)— execute a@cleat_entryworkflow against mock host callsregister_call_stub(service, operation, response)— stub acleat_callresponseregister_call_handler(service, operation, handler)— register a dynamic handlerassert_called(service, operation, times=None)— assert a call was recordeddeliver_signal(signal_name, payload)— simulate signal deliveryadvance_time(ms)— advance the simulated clock by millisecondscall_count/call_records— inspect recorded calls
Plugins(host)
Typed convenience wrappers for cleat plugin host functions. Provides methods for:
- Blobstore:
blobstore_put(key, data, ...),blobstore_get(key) - Event Triggers:
await_event(event_type, timeout_ms) - Feature Flags:
evaluate_flag(key, context) - Kafka:
produce(config_id, value, key, headers) - Notifications:
send_webhook(webhook_id, event_type, payload) - PagerDuty:
trigger_incident(...),resolve_incident(...) - Slack:
send_message(config_id, text, channel, blocks) - Webhook Ingestion:
await_webhook(source_id, event_type)
Further reading
- Cleat project README -- architecture, worker deployment, CLI reference, database schema
- Cleat WASM ABI specification -- full ABI contract, bit-packing layouts, memory layout
Deriving the surface
The host-call numbers in this file are derived. Do not count by hand, and do not copy a number from another document -- on 2026-09-13 four documents agreed at 36 and the figure was 48, because it was one number copied rather than four measurements.
python3 - <<'EOF'
import ast, pathlib
t = ast.parse(pathlib.Path('python-sdk/cleat_sdk/host_calls.py').read_text())
print(len({a.asname for n in ast.walk(t) if isinstance(n, ast.ImportFrom)
for a in n.names if a.asname and a.asname.startswith('_import_')}))
EOF
Parse, do not grep. A regex over _import_* returns 52, and three of those are
names inside comments stating the import does not exist:
# There is no _import_cleat_register_query_handler here (removed 2026-08-09).
# There is no _import_cleat_send_signal_and_wait or
# _import_cleat_reply_to_signal here (removed 2026-09-06, ...)
A text search cannot tell a binding from a sentence denying one.
The WIT world in wit/cleat.wit declares 49 functions across 17 imported
interfaces. The SDK binds 48 of them; the unbound one is
durable-register-query-handler, matching the removal note above.
Metadata
Release files for cleat-sdk 0.3.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 | Actions |
|---|---|---|---|---|
| cleat_sdk-0.3.0.tar.gz | 153.9 kB | With provenance | Details for cleat_sdk-0.3.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| cleat_sdk-0.3.0-py3-none-any.whl | Python 3 | none | any | Details for cleat_sdk-0.3.0-py3-none-any.whl |
Total release size: 293.9 kB
Release files / cleat_sdk-0.3.0.tar.gz
| Download URL | cleat_sdk-0.3.0.tar.gz |
|---|---|
| Size | 153.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d02efe65155db0fea45273f4fc9f2d4d79222ad369957fd4f82e4bee1f0f0940
|
|
BLAKE2b-256 checksum How to use checksums |
e0da74b3bbdfe0aac4701da0c96c5ede0adbc60708f49bb61ce60da12cc76c1c
|
| 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 Sep 27, 2026.
Transparency logRelease files / cleat_sdk-0.3.0-py3-none-any.whl
| Download URL | cleat_sdk-0.3.0-py3-none-any.whl |
|---|---|
| Size | 140.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1d76f20327ae8a9c211b793bf27c08761c3f840219a8a53549f857352f2cc10d
|
|
BLAKE2b-256 checksum How to use checksums |
65d9b11bcb68a957ae65eb7f357fab4861f149f74e7f6b2fcefd9bfe23c7cdfe
|
| 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 Sep 27, 2026.
Transparency logSecurity for cleat-sdk 0.3.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 cleat_sdk-0.3.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 Sep 27, 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.
To learn more about protecting yourself from malicious actors, visit our package security documentation.