Appearance
Workflows & waves
How-to — orchestrate parallel agents and multi-step pipelines.
A workflow is a default-exported async function. It runs under record/replay: on every scheduler tick it re-executes from the top, and everything already done returns instantly from the event log. Parallelism comes from waves:
ts
import type { WorkflowCtx } from "@toren/core";
export default async function (ctx: WorkflowCtx) {
// one planner task; its output drives the next wave (the planner pattern)
const plan = await ctx.wave("plan", [ctx.task("planner", ctx.input)]);
const topics = parseLines(plan.results[0].output ?? "");
// dynamic fan-out — N researchers decided at runtime by the model's plan
const research = await ctx.wave(
"research",
topics.map((t) => ctx.task("researcher", t)),
{ onTaskFailure: "collect" }, // failures reported, not fatal ("fail" = default)
);
const ok = research.results.filter((r) => r.status === "completed");
const memo = await ctx.wave("memo", [ctx.task("writer", ok.map((r) => r.output).join("\n"))]);
return memo.results[0].output ?? "";
}The rules (there are only three)
Workflow code re-executes on resume, so between awaits it must be deterministic:
- No ambient time or randomness — use
await ctx.now()/await ctx.random()(recorded once, replayed forever). - No I/O in workflow code — effects belong in tools, inside tasks.
- Sleep with
await ctx.sleep(ms)— the run parks at zero compute and wakes itself.
Plain computation (parsing, filtering, branching, loops) is fine and encouraged — that's what makes waves more expressive than a static DAG.
Editing mid-flight
Change a wave's inputs or an agent's prompt while runs are in flight: only the steps your edit actually affects re-run; everything unchanged stays cached. See Durability.
Results & coverage
ctx.wave returns results in planned order, each { taskId, status, output?, error? }. With "collect", your code decides what a partial wave means — a run that stops early reports declared gaps, never a silent success.