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:

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.