Skip to content

Workflows

Workflows are pure TypeScript orchestration: if / for / try / await / Promise.all — no graph DSL. Steps mark observable, retryable units whose outputs are cached on WorkflowStore; nested workflows provide typed, reusable modules.

ctx.stepNested workflow
PurposeObservability span + retry boundary (cached step output)Reusable unit with typed input → output
ContractClosure captures anything; return value becomes persisted outputOptional Zod input / output schemas
ReuseExtract plain TS functionsotherWorkflow.run(input)
TracingAlways creates a step nodeInner workflow defines its own steps
Best forSide effects, agent calls, or work you may skip on retry“Named, testable sub-process”

Typically step around a nested run when you want the sub-workflow visible as one waterfall bar:

workflows/search-papers.ts
import { z } from "zod";
import { adl } from "#adl";
export const searchPapers = adl.createWorkflow({
id: "search-papers",
input: z.object({ topic: z.string() }),
output: z.object({ papers: z.array(z.string()) }),
async run(input, ctx) {
return { papers: [] };
},
});
// inside another workflow's run(input, ctx):
await ctx.step("search", async ({ ctx: child }) => {
const { papers } = await searchPapers.run({ topic: input.topic }).result;
return papers;
});

Calling searchPapers.run without step is valid when you do not need an extra span.

await ctx.step("outline", async ({ ctx }) => {
await ctx.step("draft", async ({ ctx }) => {
// ...
});
});

Nested workflow run(input) accepts the same child ctx via ALS when called from inside a parent.

FieldMeaning
stepIdUnique per invocation (UUID)
nameFirst argument to step("…", …)
keyOptional disambiguator (React-style)
stepPathStable logical path from (name, key) ancestry
parentStepIdParent invocation, or null at run root

Path segments: name when key is omitted, or `${name}:${key}` when keyed.

Under a given parent, (name, key) identifies a logical step slot for the whole run:

RuleBehavior
First step("foo", …) with no keyAllowed — default slot for that name
Second+ step("foo", …) under same parentkey required — throws if omitted
Duplicate (name, key)Throw
Parallel same nameDistinct keys required
for (const topic of topics) {
await ctx.step(
"search",
async ({ ctx }) => {
/* ... */
},
{ key: topic },
);
}

“Resume” in ADL is not one feature. Steps participate in run-level retry; agents participate in conversation continuity via MessageStore. Both stores can matter on the same workflow, but they answer different questions.

ScenarioWhat the user wantsPrimary store
Continue a conversationSame chat, next turnMessageStore
Retry a failed workflowRe-run from start or past a stepWorkflowStore
Inspect / replay a past runWaterfall UI, audit logWorkflowStore

See Agents — memoryScope for conversational resume. This section covers steps and run retry.

A ctx.step callback is one atomic unit from the framework’s point of view. On retry, ADL can only:

  • Skip the step entirely — return a stored output without running the callback, or
  • Re-run the whole callback from the first line.

There is no safe way to resume “halfway through” a step body (custom logic → agent.run → more logic) without re-executing the preamble. Put non-idempotent or expensive work in its own step so retry can skip it via cached output.

await ctx.step("search", async ({ ctx }) => {
const files = await listFiles(); // runs again unless this step is skipped
const prompt = buildPrompt(files);
const out = await agent.run({ memoryScope: ctx.memoryScope("search"), user: prompt });
await uploadSummary(out); // runs again on step retry — keep uploads in a separate step if needed
return out;
});

Code between steps (top-level run body, loops, variables in closure) is not persisted. Only step return values are stored. Design workflows so retry-relevant state flows through step outputs or explicit inputs, not mutable closure variables alone.

Return value from the callback is persisted as step output on WorkflowStore and mirrored in step_finished events.

When re-running with the same workflowRunId, ctx.step checks the store before invoking the callback:

  • If getStepOutput hits → emit step_skipped, return cached output (closure body does not re-run).
  • Otherwise run callback, recordStepComplete, return output.
const first = workflow.run(input);
await first.result.catch(() => {}); // failed mid-run
// Retry: same run id — completed steps are skipped, failed step re-runs
const retry = workflow.run(input, { workflowRunId: first.workflowRunId });
await retry.result;

You can also start a new run with the same input (new workflowRunId) — that is a fresh execution with no step skip unless you implement your own policy.

Pass { force: true } to ignore cached output for one step:

await ctx.step(
"search",
async ({ ctx }) => {
/* ... */
},
{ force: true },
);

Use this when inputs changed, you need to invalidate a prior success, or you are debugging.

Step skip does not skip an LLM call by itself — it skips the entire step callback. If the step runs, agent.run executes again and typically loads the existing transcript for its memoryScope (Agents). That is conversation resume (model sees prior turns), not skipping the model.

On step retry, choose a policy explicitly:

PolicyBehavior
Continue scopeSame memoryScope; model sees prior attempt
Fork scopeNew suffix per attempt, e.g. ctx.memoryScope("search:retry-1")
Clear scopeWipe store for that scope before agent.run
CapabilityStatus
Auto resume mid-closure (TS variables)Not supported — use step outputs + skip
Checkpoints (ctx.checkpoint)Deferred
Agent episode cache (cacheable: true)Deferred
Mid-stream token resumeNot a goal
Durable crash resume without re-entryRequires persisted stores (SQLite planned) + caller
type WorkflowContext = {
readonly workflowRunId: string;
readonly stepId: string | null;
readonly stepPath: string[];
readonly parentStepId: string | null;
step: StepFn;
memoryScope: (suffix: string) => string;
emit(event: { type: "custom"; name: string; payload: unknown }): void;
};
const handle = workflow.run(input);
handle.workflowRunId;
await handle.result;
handle.cancel();

workflow.stream(input) yields live RunEvents via an async iterator while the run executes.

EventPurpose
step_startedstepId, parentStepId, name, key, path
step_finishedTerminal success with output
step_skippedReused cached output
step_failedError payload

OpenTelemetry: one span per stepId; parent link = parentStepId.

Templates are standalone — no ctx.render. Define with adl.createTemplate in your prompts module:

prompts/find-papers.ts
import { z } from "zod";
import { adl } from "#adl";
export const findPapersPrompt = adl.createTemplate({
path: "./prompts/find-papers.md",
from: import.meta.url,
inputData: z.object({ topic: z.string(), maxResults: z.number() }),
});
workflows/literature-review.ts
import { z } from "zod";
import { adl } from "#adl";
import { researcher } from "../agents/researcher";
import { findPapersPrompt } from "../prompts/find-papers";
export const literatureReview = adl.createWorkflow({
id: "literature-review",
input: z.object({ topic: z.string() }),
async run(input, ctx) {
const text = findPapersPrompt.render({ topic: input.topic, maxResults: 10 });
await researcher.run({ memoryScope: ctx.memoryScope("draft"), user: text });
return { topic: input.topic };
},
});

See Template in the API reference.

ctx.step returns a Promise. Use Promise.all with distinct keys when running parallel steps with the same name.

  • Cancellationhandle.cancel() exists but the abort signal is not yet propagated into step callbacks or child agents.