The answer. In the deployed Workflows runtime the step object passed to run() is not a plain JavaScript object. It is an RPC receiver, and a property access on one of its methods becomes a remote method lookup. step.do.bind(step) — the natural move when you want a typed adapter around do, or to pass it as a callback — asks the receiver for a method literally named bind. The instance dies with TypeError: The RPC receiver does not implement the method "bind" before its first durable step. Keep the call attached to its receiver: write the adapter so it calls step.do(...) inline, and cast only to satisfy the compiler.
The pattern.
import type { WorkflowStep, WorkflowStepConfig } from "cloudflare:workers";
// Bridges Rpc.Serializable<T> for `unknown` provider JSON without ever
// detaching `do` from its RPC receiver.
export function stepDo<T>(
step: WorkflowStep,
name: string,
configOrCb: WorkflowStepConfig | (() => Promise<T>),
cb?: () => Promise<T>,
): Promise<T> {
const s = step as unknown as { do: (...a: unknown[]) => Promise<T> };
return typeof configOrCb === "function"
? s.do(name, configOrCb) // attached call
: s.do(name, configOrCb, cb);
}
// DON'T: const doStep = step.do.bind(step); // -> remote method "bind"
// Regression test: a receiver that throws on any `bind` access.
const rpcDo = new Proxy(() => undefined, {
get: (_t, p) => { if (p === "bind") throw new TypeError("no bind"); },
apply: (_t, _this, args) => (args[1] ?? args[2])(),
});
The receipt. First production canary of HarperFlow's Workflow-based pipeline (the port of a 154-node n8n workflow to Cloudflare Workflows), 2026-07-25: the instance failed before its first step.do with exactly that TypeError. Every local test had passed, because a local step is an ordinary object that happily returns Function.prototype.bind. The adapter above plus the Proxy regression test shipped the same day (33 tests across 8 files green, TypeScript clean, Wrangler dry-runs clean), and a recovery instance then ran the status callback, opportunity discovery and single-keyword selection live before stopping on an unrelated credential rejection.
Watch out.
- Neither unit tests nor
wrangler devhand you a real RPC receiver. Simulate one, or you will only find this in production. - Destructuring (
const { do: run } = step) detaches the method the same way. We only proved.bind; treat.calland.applyas suspect too. - Cast at the boundary, not everywhere: the
as unknown asexists only to bridgeRpc.Serializable<T>forunknownJSON.
Related: production-canary-version-pinned-cutover · cron-one-tick-many-isolated-legs