SDKs
Haskell SDK
Haskell is a launch language as of 2026-07-23. It
targets the GHC Wasm backend via the ghc-wasm-meta flake, compiled
as a reactor-model core module the hopskip-wasm-host loads
directly: the same bare-core-module path as the other languages, with no
Component-wrapping step on the runtime path.
Toolchain
The dev shell exposes wasm32-wasi-ghc and
wasm32-wasi-cabal. Guests build as reactor-model core modules
(-optl-mexec-model=reactor plus POSIX libc stubs that eliminate every
WASI import), so Execution::instantiate operates on a bare core module
as it does for Rust, TypeScript, Python, and Go.
Starting
hop init ./greetings --lang haskell
This writes a Cabal package with one workflow, a unit test that runs it
against a mock host, and the cabal.project pointing at the guest SDK.
It then reads the task type back off what it wrote (by running the same
discovery hop build runs, so the printed hop run line cannot disagree
with what gets registered) and prints the commands to run. hop workflows <dir> lists what discovery finds without building anything.
The scaffold pins its task type in the source rather than leaving it to be derived, because a task type is the contract a client calls: it should not change when you rename a directory, and it should not name the language serving it. A workflow ported from Haskell to Go keeps its name.
A workflow
A workflow module is its own logic and nothing else. It exports one
binding named workflow; the build generates the export plumbing around
it.
{-# LANGUAGE OverloadedStrings #-}
module Orders.Process (workflow) where
import Hopskip
workflow :: ByteString -> Workflow Int64
workflow sku = do
logInfo "order_processing started"
reserved <- invokeActivity "reserve_inventory" sku
charged <- invokeActivity "charge_card" "amount:20"
shipped <- invokeActivity "ship_order" "carrier:default"
pure (decodeInt64LE reserved + decodeInt64LE charged + decodeInt64LE shipped)
The entry point’s own type decides what it receives and returns:
Workflow ByteString, Workflow (), ByteString -> Workflow Int64 and Text -> Workflow Text are all valid, with no annotation to
write. Declaring a parameter is what makes the SDK read the instance’s
start payload; a workflow declaring none never causes the input import
to be called at all.
For a call site that reads like a function call rather than a marshalling step, give an activity a typed handle:
reserveInventory :: Activity Sku Inventory
reserveInventory = activity "reserve_inventory" encodeSku decodeInventory
workflow sku = do
inventory <- invoke reserveInventory sku
...
activity takes your encoder and decoder. The SDK ships no
serialization format, here or anywhere.
Typed payloads
The same bring-your-own-format rule applies to the workflow’s own input
and output, with the plumbing carried for you: write a Codec instance
for your type and put Encoded 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:
data Order = Order { orderSku :: Text }
instance Codec Order where
codecContentType _ = "application/vnd.acme.order"
codecEncode = encodeUtf8 . orderSku
codecDecode = Right . Order . decodeUtf8Lenient
workflow :: Encoded Order -> Workflow (Encoded Receipt)
workflow (Encoded order) = pure (Encoded (receiptFor order))
Decoding is permissive about the declared content-type (callers
predating headers send none); contentTypeMatches is there for a
workflow that wants to check and decide for itself. The same codec
contract exists in all five SDKs.
Generated activity names
The string in activity "reserve_inventory" … is not checked. Misspell
it and everything still compiles: the
dispatch is accepted, durably recorded under
hopskip:activity.reserve_inventry@1, and never picked up, because no
worker is registered for that task type. The run blocks forever, with
every component behaving exactly as designed.
hop typegen hs removes that by generating the names from the
deployment registry:
hop typegen hs --out src/Hopskip/Deployed.hs
import Hopskip.Deployed (reserveInventory, chargeCard, ordersProcess)
reserve :: Activity Sku Inventory
reserve = activity reserveInventory encodeSku decodeInventory
A name that is not deployed is not in the module, so calling one stops
compiling. Every binding holds the whole task type: what activity and
invokeActivity dispatch, and what starts a run. Pass ordersProcess
to Hopskip.Client.startWorkflow (as a String, via
Data.Text.unpack) or to hop run.
Because the binding carries the version, importing it is how you
choose one. Two deployed versions of charge_card are two bindings,
chargeCardV1 and chargeCardV2; deploying @3 adds a third beside
them and redirects nothing you already wrote. See
Which version you call.
They are names rather than Activity handles on purpose: a handle
carries your encoder and decoder, and generated code choosing those for
you is exactly what the SDK does not do. The module depends on text
and nothing else, so a client program and a workflow guest can both
import it.
hop typegen hs --check fails a build whose committed copy no longer
matches what is deployed, including a workflow deploy, which the
TypeScript target has no reason to notice. Put it in CI, and regenerate
after a deploy that changes the set. See the
CLI reference for --module,
--namespace, and how the output path is derived.
Building and running
hop build --manifest-path ./my-workflows # a directory, not a file
hop dev --watch ./my-workflows
Point hop build at a directory and every module under it with a
top-level workflow binding is discovered, built and registered, each
under a task type derived from its module path, or the one its source
declares. A derived type never names the language or the directory it
was found in; a task type is the contract clients call, and porting or
moving a workflow must not rename it:
-- @hopskip task-type: hopskip:orders.process@2
hop dev --watch keeps that loop running: every save rebuilds what
changed, deploys it, and prints the hop run line for it.
Dependencies
By default a workflow compiles against GHC’s boot libraries only
(base, bytestring, text, containers), with nothing to declare.
Put a .cabal file above the module and the build switches to
wasm32-wasi-cabal: your build-depends is then what the workflow
gets, and it may span as many modules as the package exposes. Adding
the .cabal file is the whole of “I need a dependency”.
Testing a workflow without wasm
Control.Hopskip.Test runs a workflow value under an ordinary native
GHC against a mock host (no wasm toolchain, no server, no worker):
(total, trace) <-
runWorkflow
[ ("reserve_inventory", \_ -> encodeInt64LE 10)
, ("charge_card", \_ -> encodeInt64LE 20)
]
workflow
activityNames trace == ["reserve_inventory", "charge_card"]
It drives the real driver (one step per suspension, as the compiled
guest’s run export does), so the suspend/resume structure under test
is the real one. It does not prove determinism or replay equivalence:
the mock resolves every pending id immediately, where a real host
resolves them across process restarts and machines. Those stay with the
host integration tests and the
cross-SDK conformance matrix.
Activities
sdk/hs/hopskip-worker serves activities from Haskell, so both halves
of a workflow can be Haskell:
runWorker
(defaultWorkerConfig "http://127.0.0.1:50051")
{ workerToken = Just token
, workerActivities =
[ ("charge_card", \payload -> do
declined <- charge payload
if declined
then throwIO (nonRetryable "card issuer declined" "CardDeclinedError")
else pure "ok")
]
}
Both the worker and the client are native
Haskell programs reaching gRPC through a thin Rust bridge
(core/hopskip-client-ffi), because Haskell has no comparably mature,
easy-to-build gRPC stack of its own.
The explicit continuation
Where JavaScript, Python, and Rust get suspension from their own
async/generator machinery, Haskell’s suspend/resume boundary is an
explicit, hand-written continuation type rather than GHC’s
forkIO/RTS-scheduler machinery. This upholds the same
quiescence invariant
(the native stack is empty at every await) that the other languages get
for free.
-- An await yields a suspended value carrying the pending id and the
-- continuation to resume with its result. The host snapshots linear memory at
-- exactly this boundary.
data WorkflowStep a
= Done a
| Suspended Int64 (ByteString -> Workflow a)
You write ordinary do-notation; the SDK threads the continuation for
you.
Determinism
The same guarantees apply: workflow-visible time is
logical event time,
entropy is seeded from the workflow ID, and network I/O belongs in
activities. The reactor-model build with libc stubs removes the WASI
imports that would otherwise leak non-determinism. There is no
MonadIO instance for Workflow, so lifting arbitrary guest IO into
workflow code is a type error rather than a convention a reviewer has to
remember to check.
Current status
Haskell is a launch language but not yet at full feature parity with Rust/TypeScript/Python. Still missing:
- No port of the fallible-activity failure envelope yet.
- No concurrent-await combinator yet.
- No WIT contract support, so no queries or signals.
This does not block launch status; it matches how the other SDKs shipped ahead of full parity. Every guest-facing change must still pass the cross-SDK conformance suite in all five languages, Haskell included, with interchangeable histories, before it ships.
Why Haskell
Haskell’s runtime is nothing like the other guest languages, so a purely functional language running through the same sandbox is strong evidence that determinism and snapshotting are properties of the sandbox, not of any one language’s accident of implementation.