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.

To expose an agent as a workflow that takes a string user message, use adl.createWorkflowFromAgent(agent) (optional { id }; default ${agent.id}-as-workflow). That is the workflow-shaped counterpart of adl agent run.

By default, otherWorkflow.run(input) nests: it joins the active parent via ALS (or explicit parentCtx), shares that workflowRunId, and records inner steps on the parent’s event stream — they show up in that run’s inspector tree.

{ isolated: true } starts a separate run instead of nesting:

Nested (default){ isolated: true }
ParentJoins ALS / parentCtxIgnores parent
workflowRunIdShared with parentNew id
PersistenceEvents on the parent runOwn row on WorkflowStore
Inspector treeInner steps under the parentOwn tree, not folded into the caller

Isolated runs are always persisted, but whether they appear in the inspection UI is determined by the project config. To leave a workflow out of the UI, do not included in the project config’s workflow array.

// Inside a parent workflow (or an agent episode that happens to be in one):
await helper.run(input, { isolated: true }).result;

Conversation titleWorkflow uses this so naming a chat does not inject steps into another workflow’s tree.

Zod input / output on createWorkflow both validate at runtime and infer TypeScript types. Zod is optional. Pin types with generics (or by annotating run) when you do not want a runtime schema:

type SearchInput = { topic: string };
type SearchOutput = { papers: string[] };
export const searchPapers = adl.createWorkflow<SearchInput, SearchOutput>({
id: "search-papers",
async run(input, ctx) {
return { papers: [] };
},
});

workflow.run then type-checks callers against SearchInput and handle.result is Promise<SearchOutput>. Add Zod later if you want parse/defaults without changing those types.

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 here means re-entering a workflow run: skip completed steps via cached output, re-run the rest. That uses WorkflowStore. Inspection replay also reads this store; it does not re-execute the workflow.

MessageStore is not a resume path. Same memoryScope on a later agent.run is ordinary conversation memory (load / append / save). See Agents — Calling an agent. The stores only meet when a retried step calls an agent again — skip is WorkflowStore; the transcript the model sees is MessageStore.

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.memoryScopeWithSuffix("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 memory (model sees prior turns), not skipping the model and not restoring the workflow.

On step retry, choose a policy explicitly:

PolicyBehavior
Continue scopeSame memoryScope; model sees prior attempt
Fork scopeNew suffix per attempt, e.g. ctx.memoryScopeWithSuffix("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-entrySQLite stores persist I/O; mid-closure resume still deferred
type WorkflowContext = {
readonly workflowRunId: string;
readonly stepId: string | null;
readonly stepPath: string[];
readonly parentStepId: string | null;
readonly signal: AbortSignal;
step: StepFn;
memoryScopeWithSuffix: (suffix: string) => string;
emit(name: string, payload?: unknown): void;
setTitle(title: string): Promise<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.

ctx.setTitle(title) sets the inspector display name for this workflow run. Call it at the start of run, after a first step, or just before returning — blank titles are ignored.

export const literatureReview = adl.createWorkflow({
id: "literature-review",
input: z.object({ topic: z.string() }),
async run(input, ctx) {
await ctx.setTitle(`Literature review: ${input.topic}`);
// ...
},
});
EventPurpose
step_startedstepId, parentStepId, name, key, path
step_finishedTerminal success with output
step_skippedReused cached output
step_failedError payload
customAuthor event from ctx.emit(name, payload?)

ctx.emit(name, payload?) always persists as type: "custom". You do not pass a type, and you cannot emit reserved runtime types (workflow_*, step_*, agent_*). Every RunEvent has a per-run runSeq (SQLite column run_seq), distinct from the process-wide logSeq on the in-memory event log.

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

Templates are standalone — no ctx.render. They use Handlebars ({{var}}, {{#each}}, …) after Zod parse. 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.memoryScopeWithSuffix("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.

handle.cancel() aborts ctx.signal for that run. The runtime:

  • Rejects in-flight ctx.step callbacks (they can also listen to ctx.signal)
  • Links child agent.run / agent.stream AbortControllers to the same signal, so streamText stops
  • Emits workflow_cancelled (and step_failed for the interrupted step)

Nested workflow.run() shares the parent’s abort. { isolated: true } runs keep their own signal.

const handle = review.run({ topic: "CRISPR delivery" });
handle.cancel();
await handle.result.catch((error) => {
// DOMException AbortError (or the abort reason)
});