SDKs

Python SDK

The Python SDK runs ordinary async/await workflow code on Pyodide, a prebuilt CPython for WebAssembly, hosted alongside the TypeScript runtime. Awaits are real quiescence boundaries, and the annotations you use (@durable, Searchable, @entity) reach the same workflow ABI host imports as every other launch language.

Install

pip install hopskip-worker hopskip-client

During the preview, build guests with sdk/py/pyodide-guest/build.sh from the dev shell. The first build fetches and hash-checks the pinned Pyodide distribution; after that it needs no network.

Starting

hop init ./greetings --lang python

This writes a project in the shape this SDK uses, already pointing at the guest SDK, and prints the exact hop build and hop run lines for what it wrote.

From there, point the build at the directory and every workflow under it is discovered (a workflow here being a .py with a top-level __hopskip_workflow =), each under a task type derived from its own name:

hop build --manifest-path ./my-workflows
hop dev --watch ./my-workflows

hop dev --watch rebuilds and redeploys what changed on every save and prints the hop run line for it.

A derived task type never contains the implementation language or the source layout. A task type is the contract a client calls, so porting a workflow to another language (or moving its directory) must not rename it. Pin one explicitly when you want a name that outlives the thing it was derived from:

# @hopskip task-type: hopskip:orders.process@2

# @hopskip skip excludes a file the rule would otherwise match, and hop workflows <dir> lists what discovery finds without building anything.

A workflow

The SDK’s names (durable, activity, sleep, …) are ambient in a workflow module - the build embeds the runtime prelude, so there is nothing to import:

def encode_i64(value):
    return value.to_bytes(8, "little", signed=True)

def decode_i64(data):
    return int.from_bytes(data, "little", signed=True)

async def order_processing():
    reserved = await activity("reserve_inventory", b"sku:ABC")
    charged = await activity("charge_card", b"amount:20")
    total = decode_i64(reserved) + decode_i64(charged)
    return encode_i64(total)

__hopskip_workflow = durable(order_processing)

Each compiled Python guest module exposes a single __hopskip_workflow export, so distinct workflows are separate entry-point modules.

A workflow module is self-contained: the build bundles it with the SDK prelude, and the interpreter’s own standard library is what it can import - pulling pip packages into a workflow is not supported yet. Activities are ordinary Python processes and install anything they like.

Typed payloads

The SDK ships no serialization format, by design - but it carries the one you choose. Supply a Codec (any object with content_type, encode, decode) and wrap the entry point with encoded(...): the input is decoded before your function sees it, the output encoded after, and the result’s content-type header declares the encoding without the workflow mentioning it:

class OrderCodec(Codec):
    content_type = "application/vnd.acme.order+json"
    def encode(self, value):
        return json.dumps(value).encode("utf-8")
    def decode(self, data):
        return json.loads(data.decode("utf-8"))

__hopskip_workflow = durable(encoded(OrderCodec(), ReceiptCodec(), process))

Pass None for either side to keep it raw bytes. Decoding is permissive about the declared content-type; content_type_matches(codec) is there for a workflow that wants to check and decide for itself. The same codec contract exists in all five SDKs.

Signals, queries & updates

A workflow’s externally callable operations are plain top-level functions marked # @handler, with the kind - @query (read-only), @signal (fire-and-forget), @update (mutate and return) - declared in a WIT contract hop build --wit contract.wit checks and wires:

# @handler
def get_status():
    return b"pending"

# @handler
def cancel():
    pass

# @handler
def add_item(item):
    return b"item-added:" + item

A @query handler that emits a command - an activity, a timer, a searchable mark - raises ContractViolation at the call site, and the engine’s own history check backstops it.

Handler payloads take the same codecs workflow inputs do - contract_encoded is the contract-side counterpart of encoded(), sitting between the marker and the def:

# @handler
@contract_encoded(order_codec, receipt_codec)
def add_item(order):
    return receipt_for(order)

Clients call the handlers with the matching verbs, bytes-shaped or carrying the same codec choice; a contract dispatch has no header channel, so no content-type travels with it - the codec is the contract, on both ends of the call:

client.signal_workflow("prod", "order-7", "cancel")
status = client.query_workflow("prod", "order-7", "get-status")
receipt = client.update_workflow(
    "prod", "order-7", "add-item", order,
    input_codec=order_codec, output_codec=receipt_codec,
)

Generated activity names

The activity name is not checked. Misspell it and everything still compiles: the dispatch is accepted, durably recorded under a task type no worker is registered for, and never picked up. The run blocks forever, with every component behaving exactly as designed.

hop typegen py removes that by generating the names from what is deployed:

hop typegen py --out workflows/hopskip_deployed.py
from hopskip_deployed import RESERVE_INVENTORY, CHARGE_CARD

reserved = await activity(RESERVE_INVENTORY, b"sku:ABC")
charged = await activity(CHARGE_CARD, b"amount:20")

A name that is not deployed is not in the generated file, so calling one stops compiling. Each constant carries its whole task type, including the version, so importing one is how you choose which version to call. See Which version you call.

hop typegen py --check fails a build whose committed copy no longer matches the plan it was generated from. Put it in CI, and regenerate after a deploy that changes the set.

Searchable state

async def order_processing():
    status = Searchable("status", "pending")     # re-extracted at each checkpoint
    mark_searchable("customer_tier", "gold")     # one-shot tag

    reserved = await activity("reserve_inventory", b"sku:ABC")
    status.set("reserved")

    charged = await activity("charge_card", b"amount:20")
    status.set("charged")
    return charged

__hopskip_workflow = durable(order_processing)

Both entry points (the Searchable wrapper and the bare mark_searchable free function) reach the emit_searchable host import.

Dataset lineage

Declare what a run reads and writes and it joins your OpenLineage graph. See the lineage guide:

dataset_input("orders_source", "postgres://db:5432", "shop.public.orders")
dataset_output("rollup", "s3://warehouse", "orders/2026-07-30.parquet")

Perpetual entities

An @entity is a workflow that carries state across event horizons. When Core seals and reopens the log, the host hands the next generation the sealed snapshot; resume_entity reconstructs it, or starts fresh:

@entity
class CounterEntity:
    def __init__(self, total):
        self.total = total

    def to_snapshot(self):
        return self.total.to_bytes(8, "little", signed=True)

    @classmethod
    def resume_from_snapshot(cls, snapshot):
        return cls(int.from_bytes(snapshot, "little", signed=True))

async def counter_workflow():
    state = resume_entity(lambda: CounterEntity(0))
    tick = await activity("tick", b"")
    state.total += int.from_bytes(tick, "little", signed=True)
    return state.total.to_bytes(8, "little", signed=True)

__hopskip_workflow = durable(counter_workflow)

The snapshot encoding is identical across languages, so a snapshot written by the Rust or TypeScript version resumes the Python guest identically. That is the interchangeable-history requirement.

Streaming

async def stream_processing():
    for chunk in [b"chunk001", b"chunk002", b"chunk003"]:
        try:
            await yield_chunk(chunk)
        except StreamDisconnectedError:
            await activity("reroute-store", b"disconnected")
            return b"rerouted"
    return b"streamed:3"

__hopskip_workflow = durable(stream_processing)

What’s enforced for you

Non-deterministic operations are unavailable inside the sandbox. Wall-clock time, entropy, and network I/O are either rebound to deterministic host imports or trap. Do all I/O in activities. See Determinism & the sandbox.