Writing a flow

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.

What counts as a step

A step is a method declared directly on the class. In declaration order, and with four exceptions:

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.

Steps run in order, and each one is recorded

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.

State between steps

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:

WhereFor 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 });
}

What a step can read

Three different things arrive with a run, and they are easy to confuse:

So payload is what happened, input is how this deployment is set up, and meta is the envelope it arrived in.

Logging

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.

When a step fails

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) });
}

Work a step starts but does not return

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.

Where the file lives

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.

Next