SDKs

Rust SDK

Rust is the reference SDK. Workflow code compiles directly to a Wasm component the universal worker hosts, and the guest runtime shares its lineage with Core itself. Async workflows suspend at await via Rust’s own async machinery, which keeps pending state on the heap and so upholds the quiescence invariant.

Install

# Cargo.toml
[dependencies]
hopskip-sdk = "0"

Build with the wasm32-wasip1 target from the dev shell.

Starting

hop init ./greetings --lang rust

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 Cargo.toml with a cdylib lib and an hopskip-sdk dependency), 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

Name the entry function with the workflow! macro - it emits the export quartet the host drives - and call activity::invoke(name, bytes) to cross the sandbox boundary. Payloads on the ABI are bytes; the entry point itself may take a String, i64, Vec<u8>, or nothing, and return the same set:

use hopskip_sdk::activity;

hopskip_sdk::workflow!(order_processing);

async fn order_processing(sku: String) -> Vec<u8> {
    let reserved = activity::invoke("reserve_inventory", sku.into_bytes()).await;
    let charged = activity::invoke("charge_card", b"amount:20".to_vec()).await;
    let shipped = activity::invoke("ship_order", reserved).await;
    [charged, shipped].concat()
}

Unlike the interpreted guests, a single Rust crate can export multiple workflows, so related workflows live together in one compiled module. activity::invoke_fallible is the variant that surfaces a typed, catchable ActivityError instead of trusting the payload.

Typed payloads

The SDK ships no serialization format, by design - but it carries the one you choose. Implement Codec for your type and put Encoded<T> in the entry point’s signature: 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:

use hopskip_sdk::{Codec, CodecError, Encoded};

struct Order { sku: String }

impl Codec for Order {
    const CONTENT_TYPE: &'static str = "application/json";
    fn encode(&self) -> Vec<u8> { my_json::to_vec(self) }
    fn decode(bytes: &[u8]) -> Result<Self, CodecError> {
        my_json::from_slice(bytes).map_err(|e| CodecError::new(e.to_string()))
    }
}

hopskip_sdk::workflow!(process);

async fn process(order: Encoded<Order>) -> Encoded<Receipt> {
    Encoded(Receipt::for_order(&order))
}

Decoding is permissive about the declared content-type (callers predating headers send none); Encoded::<T>::content_type_matches() 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 surface is declared in a WIT file whose doc comments carry the kind - @query (read-only), @signal (fire-and-forget), @update (mutate and return) - and implemented as plain functions in a mod handlers:

interface order-workflow {
  /// @query
  get-status: func() -> string;
  /// @signal
  cancel: func();
  /// @update
  add-item: func(item: string) -> result<u32, string>;
}
mod handlers {
    use hopskip_sdk::Encoded;

    pub fn get_status() -> String { "pending".to_owned() }
    pub fn cancel() {}
    pub fn add_item(item: Encoded<Order>) -> Encoded<Receipt> {
        Encoded(receipt_for(&item))
    }
}

Handler arguments and results are typed exactly as workflow inputs and outputs are: Vec<u8> verbatim, String, i64, or Encoded<T> through your own Codec - the generated glue marshals through ContractInput/ContractOutput, so the handler’s own signature picks the conversion. 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.

hop build --wit contract.wit checks the handlers against the contract (a name or arity mismatch is a build error) and generates the dispatch glue. A @query handler that emits a command - an activity, a timer, a searchable mark - is a runtime error, enforced both in the SDK and by the engine’s own history check.

Clients call them with the matching verbs, bytes-shaped or typed through the same Codec:

client.signal_workflow("prod", "order-7", "cancel", vec![]).await?;
let status = client.query_workflow("prod", "order-7", "get-status", vec![]).await?;
let receipt: Receipt = client
    .update_workflow_encoded("prod", "order-7", "add-item", &order)
    .await?;

A workflow that wants to wait for a signal parks on hopskip_sdk::condition(|| state.approved), which suspends until the next landed signal or update makes the predicate true.

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 rust removes that by generating the names from what is deployed:

hop typegen rust --out src/hopskip_deployed.rs
use crate::hopskip_deployed::{CHARGE_CARD, RESERVE_INVENTORY};

let reserved = activity(RESERVE_INVENTORY, &order.sku).await?;
let charged = activity(CHARGE_CARD, order.total).await?;

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 rust --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

use hopskip_sdk::{activity, mark_searchable, Searchable};

async fn order_processing(sku: String) -> Vec<u8> {
    let mut status = Searchable::new("status", "pending");
    mark_searchable("customer_tier", "gold");

    let reserved = activity::invoke("reserve_inventory", sku.into_bytes()).await;
    status.set("reserved");

    let charged = activity::invoke("charge_card", reserved).await;
    status.set("charged");
    charged
}

Dataset lineage

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

use hopskip_sdk::lineage;

lineage::input("orders_source", "postgres://db:5432", "shop.public.orders");
lineage::output("rollup", "s3://warehouse", "orders/2026-07-30.parquet");

Perpetual entities

use hopskip_sdk::{activity, entity::Entity};

struct CounterEntity { total: i64 }

impl Entity for CounterEntity {
    fn to_snapshot(&self) -> Vec<u8> { self.total.to_le_bytes().to_vec() }
    fn resume_from_snapshot(snapshot: &[u8]) -> Self {
        CounterEntity { total: i64::from_le_bytes(snapshot.try_into().unwrap()) }
    }
}

hopskip_sdk::mark_entity!(CounterEntity);

async fn counter_workflow() -> i64 {
    let mut state = hopskip_sdk::entity::resume(|| CounterEntity { total: 0 });
    let tick = activity::invoke("tick", Vec::new()).await;
    state.total += i64::from_le_bytes(tick.try_into().unwrap());
    state.total
}

The snapshot bytes are byte-compatible with the Python and TypeScript entities, so a snapshot from any language resumes any other. The cross-SDK conformance suite verifies this.

Workers and activities

Compiled workflow modules are hosted by the universal worker binary (hopskip-wasm-worker, itself Rust embedding Wasmtime), which serves everything deployed into its registry.

Activities are served by their own worker process, written as ordinary Rust against the core/hopskip-worker helpers: failure classification, completion and heartbeat frames, task tokens. The worker registers for the activity’s task type, runs the function on each dispatch, and reports completions. sdk/rust/dogfood-worker is a complete, commented example (register, receive dispatch, heartbeat, complete), and sdk/rust/migration-worker is a second one.

Why Rust is the reference

The host, Core, and the Rust guest SDK share types and the ABI definition, so new ABI features land here first and are then mirrored across the other four SDKs. None of them may ship a guest-facing feature until it passes the cross-SDK conformance suite in all five. See Compatibility & versioning.