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.fetchinside 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.