SDKs

TypeScript SDK

The TypeScript SDK lets you write workflows in ordinary async/await. Every await is a quiescence boundary handled by the JS engine’s own async-function suspension, not a hand-assembled state machine. Workflow modules are bundled to an ES module a real V8 isolate runs; the host answers the hopskip:runtime import with the ABI at instantiate time.

Install

No npm packages are published yet. @hopskip/runtime / @hopskip/worker are the intended install surface, not a current one. During the preview, build against the in-tree runtime at sdk/ts/hopskip-runtime from within the dev shell.

Starting

hop init ./greetings --lang typescript

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 .ts exporting a function marked with a taskType property):

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 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. The marker carries it explicitly, so there is nothing derived to drift. // @hopskip skip excludes a file the rule would otherwise match, and hop workflows <dir> lists what discovery finds without building anything.

A workflow

A workflow is a plain exported async function - there is no wrapper to apply. A single-workflow bundle exports it as default; a bundle holding several marks each with the task type it serves. Call activity(name, bytes) to cross the sandbox boundary. Values on the ABI are byte buffers (Uint8Array), with helpers for common encodings:

import { activity, encodeAscii, encodeInt64LE, decodeInt64LE } from "@hopskip/runtime";

export const orderProcessing = async (): Promise<Uint8Array> => {
  const reserved = await activity("reserve_inventory", encodeAscii("sku:ABC"));
  const charged = await activity("charge_card", encodeAscii("amount:20"));
  const shipped = await activity("ship_order", encodeAscii("carrier:default"));

  const total = decodeInt64LE(reserved) + decodeInt64LE(charged) + decodeInt64LE(shipped);
  return encodeInt64LE(total);
};
orderProcessing.taskType = "hopskip:orders.process@1";

Returning a string crosses as UTF-8 and a bigint as a little-endian i64; declaring a Uint8Array parameter receives the start payload. The conversions happen host-side, so the same source needs no entry boilerplate at all.

Typed payloads

The SDK ships no serialization format, by design - but it carries the one you choose. Implement Codec<T> for your type 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:

import { encoded, type Codec } from "@hopskip/runtime";

const orderCodec: Codec<Order> = {
  contentType: "application/json",
  encode: (order) => encodeUtf8(JSON.stringify(order)),
  decode: (bytes) => JSON.parse(decodeUtf8(bytes)) as Order,
};

export default encoded(orderCodec, receiptCodec, async (order: Order): Promise<Receipt> => {
  return process(order);
});

Pass null for either side to keep it raw bytes. Decoding is permissive about the declared content-type (callers predating headers send none); contentTypeMatches(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 exported functions marked with a contract property - "query" (read-only), "signal" (fire-and-forget), or "update" (mutate and return):

import { condition, query, signal } from "@hopskip/runtime";

let approved = false;

export const approve = signal(() => {
  approved = true;
});

export const status = query(() => encodeAscii(approved ? "approved" : "waiting"));

export const waiter = async () => {
  await condition(() => approved);
  return encodeAscii("done");
};
waiter.taskType = "hopskip:approval.wait@1";

The query()/signal()/update() wrappers also guard the read-only rule at the call site: a @query handler that calls activity(), sleep(), or any other command throws a ContractViolation naming the handler and the attempted call. Assigning fn.contract = "query" on a bare function declares the same handler without the call-site guard - the host’s own history check backstops both, so a query can never smuggle a command past it. condition(predicate) suspends until the next landed signal or update makes the predicate true.

Handler payloads take the same codecs workflow inputs do - encodedHandler is the contract-side counterpart of encoded():

export const add_item = update(
  encodedHandler(orderCodec, receiptCodec, (order) => receiptFor(order)),
);

The client’s dispatch calls carry the matching choice (client.updateWorkflow(ns, wf, "add_item", order, { inputCodec, outputCodec })). 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.

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

hop typegen ts --out src/hopskip-activities.d.ts
import { reserveInventory, chargeCard } from "hopskip:activities";

const reserved = await activity(reserveInventory, encodeAscii("sku:ABC"));
const charged = await activity(chargeCard, encodeAscii("amount:20"));

For TypeScript the generated file is an ambient declaration: the module itself is built by the V8 host at instantiate time from the same registry read, so what your editor offers and what the host binds cannot drift.

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

Tag variables so Core extracts them into the visibility projection at each checkpoint:

import { activity, Searchable, markSearchable } from "@hopskip/runtime";

export const orderProcessing = async () => {
  const status = new Searchable<string>("status", "pending");
  markSearchable("customer_tier", "gold");

  await activity("reserve_inventory", /* … */);
  status.set("reserved");

  await activity("charge_card", /* … */);
  status.set("charged");
};
orderProcessing.taskType = "hopskip:orders.process@1";

Dataset lineage

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

import { declareDatasetInput, declareDatasetOutput } from "@hopskip/runtime";

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

Both return false without emitting for coordinates that cannot be represented faithfully, including non-ASCII ones, which this SDK declines rather than mangling through its ASCII-only scalar encoding.

The worker

Activity functions run in a worker process built on HopskipWorker (sdk/ts/hopskip-worker). It opens a persistent stream to the server, registers for each activity’s task type, and runs the handler on every dispatch:

import { HopskipWorker } from "sdk/ts/hopskip-worker";

const worker = new HopskipWorker("127.0.0.1:50051", {
  workerId: "orders-worker",
  token: process.env.HOPSKIP_TOKEN,
});

worker.registerActivity("hopskip:activity.charge_card@1", async (payload) => {
  return encodeI64(await billing.charge(decodeI64(payload)));
});

await worker.start();

Handlers receive and return payload bytes; the encodings are yours. See Activities & retries for failure classification, retries, and heartbeating.

How suspension works

TypeScript workflows run on a V8 isolate. At each await, V8 parks the frame on its own heap rather than the native stack (the quiescence invariant), and a snapshot is that heap. You get durability without writing any state-machine boilerplate, and without an SDK wrapper around your workflow: the parking is V8’s, not the SDK’s.

The isolate is a real V8 rather than a JavaScript interpreter compiled to wasm: a wasm guest may not generate executable code at runtime, so an interpreter pays roughly 33x per call, a ceiling rather than a tuning problem.

What’s enforced for you

  • Date.now() returns logical event time.
  • Math.random() is seeded from the workflow ID.
  • fetch inside workflow code fails to link; do I/O in activities.

See the Rust, Python, and Haskell SDKs for the same workflow in other languages. Their histories are interchangeable.