A flow is a TypeScript class that extends Workflow. Every method you declare on it is a
step, and the platform runs those steps in the order you wrote them.
import { Workflow } from "konnecthub/flow";
export default class OrderReceived extends Workflow {
fetchOrder() {
const order = this.payload as { id: string; total: number };
this.log("INFO", `order ${order.id} for ${order.total}`);
return order;
}
async recordIt() {
const order = await this.getResult<{ id: string }>("fetchOrder");
await fetch("https://example.com/orders", {
method: "POST",
body: JSON.stringify(order),
});
}
}
That flow has two steps, fetchOrder and recordIt. There is no run method and no
registration call — declaring the method is what creates the step.
A step is a method declared directly on the class. In declaration order, and with four exceptions:
constructor is not a step.onFailure is not a step — it is a hook, described below.#private methods are not steps, because private methods are invisible to the platform's
discovery. This is how you write a helper that lives on the class.Anything else you declare runs, so a helper you meant to call yourself becomes a step of its
own. Put shared code in another file, or make it #private.
The platform runs step one, stores what it returned, then runs step two. A step's return value is persisted before the next step starts, which is what makes it readable later:
const order = await this.getResult<Order>("fetchOrder");
getResult reads any earlier step in the same execution. It takes the step's name, so renaming
a method changes what later steps can ask for.
The recording is not only for your benefit — it is the execution's record. Each step appears on the execution page with its result, its logs and its timing, which is why a flow of five small steps is far easier to debug than one method that does everything.
Instance properties do not survive. A worker can be torn down and restarted between steps,
and when it is, your this.total = 0 is gone. Two things do survive:
| Where | For what |
|---|---|
A step's return value, read with getResult(step) | The output of that step |
this.context, written with await this.setContext(value) | State you accumulate as the run progresses |
setContext writes through to the platform before it resolves, so the value is durable the
moment the await returns.
async tally() {
const seen = (this.context as { seen: number } | undefined)?.seen ?? 0;
await this.setContext({ seen: seen + 1 });
}
Three different things arrive with a run, and they are easy to confuse:
this.payload — the trigger's data. A webhook's JSON body, a schedule's payload, an
event's payload. It changes on every run.this.input — the deployment's configuration, declared as an input schema on the flow
version and filled in when the deployment is created. It is the same for every run of that
deployment.this.meta — data about the delivery rather than the delivery itself. For a webhook
that is { method, headers, query }.So payload is what happened, input is how this deployment is set up, and meta is the
envelope it arrived in.
this.log("INFO", "matched a customer", { customerId: id });
The level comes first, and the optional third argument is structured data attached to the line.
console.log also works and is captured, but without step attribution — this.log records
which step wrote the line.
A step that throws fails the execution. Whether it is retried is a property of the deployment, not of the code — see Retries.
You can react to a terminal failure with onFailure, which is a hook rather than a step:
onFailure(step: string, error: unknown) {
this.log("ERROR", `giving up after ${step}`, { error: String(error) });
}
A step is finished when its method's returned promise settles. Anything you leave running past
that point — a floating fetch, a setTimeout — is invisible to the platform, and on the last
step the worker is torn down while that work is still in flight.
waitUntil holds the step open for it:
async notify() {
const result = await this.doWork();
this.waitUntil(fetch(auditUrl, { method: "POST", body: "..." }));
return result;
}
Common detached work is detected and waited on even without this, but the detector cannot see every primitive. If it matters, register it. A promise registered this way that rejects does not fail the step — it is logged as a warning against it.
A flow is a directory. Authoring in the browser editor, you see the same layout the CLI writes:
flows/order-received/
flow.json name and description
inputs.json the input schema this flow declares
main.ts the entrypoint — the default-exported class
The directory name is the flow's name, and it is the identity key: renaming it is a decision the platform asks you about rather than something it infers.