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.