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 inputSchema / outputSchema
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",
inputSchema: z.object({ topic: z.string() }),
outputSchema: 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), allocates a new workflowRunId, and records parentWorkflowRunId on the child (plus parentStepId when the call happens inside an active ctx.step). The child’s steps and agents bind to the child run (own step cache and event stream). Abort is linked to the parent.

Live context fields mirror the store link: nested bodies see ctx.parentWorkflowRunId set to the caller’s run id and ctx.rootWorkflowRunId set to the outermost run in the nest tree. Top-level and { isolated: true } runs have parentWorkflowRunId === null and rootWorkflowRunId === workflowRunId. ctx.stepId is still null at every workflow body root — including nested ones — so it does not mean “this invocation is the entry point.” Gate root-only behavior (for example setTitle) on ctx.parentWorkflowRunId === null.

Memory helpers:

HelperFormulaUse when
memoryScopeWithSuffix(suffix)${rootWorkflowRunId}:${suffix}Cross-phase agents in a nest tree (default)
runLocalScope(suffix)${workflowRunId}:${suffix}Conversation must stay on this immediate run

{ isolated: true } starts its own root, so its memoryScopeWithSuffix scopes do not join the caller’s tree.

{ isolated: true } starts an unlinked run (no parent pointer):

Nested (default){ isolated: true }
ParentJoins ALS / parentCtxIgnores parent
workflowRunIdNew idNew id
Parent linkparentWorkflowRunId = parent’s id (also on live ctx)null
Root idrootWorkflowRunId = outermost nest idEquals this run’s workflowRunId
Step linkparentStepId = calling step, or null at workflow rootnull
PersistenceOwn row on WorkflowStoreOwn row on WorkflowStore
InspectorInline under the calling step (or under the workflow row when called at root); expand to load the child’s stepsOwn tree; not in another run’s child list

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 include it in the project config’s workflow array. Non-root (nested) child runs can be hidden from the main run list (roots-only) via the Non-Root toggle. Opening a non-root run page still shows a parent back-link.

// 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 inputSchema / outputSchema 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 starts a new attempt after a failure. Retry from a step uses the same mechanism: you choose the step, and ADL starts a new attempt from there. Still-valid work is replayed by returning stored step outputs through ctx.step (no callback). Work that must run again gets new run/step IDs and fresh events. The prior attempt forest stays immutable. That uses WorkflowStore (seedRetryAttempt). Inspection replay also reads this store; it does not re-execute the workflow.

MessageStore holds the transcript the model sees. Using the same memoryScope on a later agent.run is ordinary conversation memory (load / append / save) — see Agents — Calling an Agent.

On retry, that store is also how a skipped step keeps its transcript available:

  1. While a step runs, ADL records each scope the message store loads, saves, copies, or deletes, plus the transcript in that scope when the step finishes.
  2. If the new attempt skips that step, those recorded transcripts are written onto this attempt’s matching ${rootWorkflowRunId}:${suffix} / ${workflowRunId}:${suffix} scopes (memoryScopeWithSuffix / runLocalScope) — the scope as it was before the retried step.
  3. Later writes on the prior attempt stay on that attempt. A scope id the workflow chose itself (not via those helpers) is not copied; it stays on that same row.

memoryScopeWithSuffix keys the root of the nest tree; runLocalScope keys the immediate run (see Nested and Isolated Runs). A new attempt gets a new root id, so both helpers name new scopes until a skipped step restores prior transcripts.

What that means for an agent.run inside a skipped vs re-run step is under Agents on Retry.

Each workflow.run() gets its own workflowRunId. Nested (non-isolated) invocations record parentWorkflowRunId and parentStepId (the parent ctx.stepId at nest time, or null at workflow root). { isolated: true } detaches — no parent link — and stays out of attempt cascade (e.g. titleWorkflow). Re-entering a path that calls an isolated helper again always starts a fresh run.

A ctx.step callback is one atomic unit from the framework’s point of view. On a new attempt, 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. Steps are pure by default (agent memory scopes are the exception — see Agents on Retry): do not mutate captured closures; anything a later step needs must appear in an earlier step’s output (or run input).

Step output cache keys are the logical path segments (name or name:key), not ephemeral parent step UUIDs. Re-entering an ancestor can still skip prefix siblings under that ancestor.

const first = workflow.run(input);
await first.result.catch(() => {});
const failed = await store.getLatestEvent({ workflowRunId: first.workflowRunId }, "step_failed");
const attempt = await store.seedRetryAttempt({
fromWorkflowRunId: first.workflowRunId,
fromStepId: failed!.stepId,
});
const retry = workflow.run(input, {
workflowRunId: attempt.newRootRunId,
retryAttempt: attempt,
});
await retry.result;

Seeding copies still-valid run/step projections with replayOf* links and seeds the new attempt’s skip cache. The target step, its descendants, path/parentWorkflowRunId ancestors, time-subsequent steps (step_started.at > T_end), and { pure: false } steps re-execute. Unknown fromStepId throws.

The same seed is available as seedRetryFromCursor with a TraceCursor. Prefer effectId (the gated effect_intent.id on the run log); stepId is the UI alias for a step-boundary intent. Resolving an effectId maps to the enclosing step and reuses the step-atomic skip/re-exec rules above — tool or approval intents inside a step still re-run the whole step. Unknown effectId, a conflicting stepId, and root-level intents (stepId null) throw. runSeq cursors are not implemented.

const intent = await store.getLatestEvent({ workflowRunId: first.workflowRunId }, "effect_intent");
const attempt = await store.seedRetryFromCursor({
cursor: { workflowRunId: first.workflowRunId, effectId: intent!.intent.id },
});

Same-workflowRunId re-entry (without seeding) still skips via the path-stable cache when outputs remain — useful for simple mid-run retries, but superseded for forest retry by attempt lineage.

The inspection UI exposes this as Retry (run header and the workflow-row menu) and Retry from here (step menu and step inspector). Retry is refused while any run in the attempt forest is still running. Other hosts call seedRetryAttempt / seedRetryFromCursor the same way.

OptionScopeBehavior
force: trueThis call onlyBypass skip cache for this invocation
pure: falseDeclared on the stepOn every new attempt that reaches this path: always re-exec; never skip/copy
await ctx.step(
"upload",
async () => {
/* side effect */
},
{ pure: false },
);
await ctx.step(
"search",
async ({ ctx }) => {
/* ... */
},
{ force: true },
);

Skipping a step skips the entire callback — including any agent.run inside it. It does not selectively skip only the LLM call. How skipped-step transcripts land on the new attempt is under Resumability above.

When the step re-runs, agent.run executes again and typically loads whatever is already on its memoryScope (Agents). With memoryScopeWithSuffix, that is usually the new attempt’s empty scope — the step does not get a restored snapshot of its own prior writes. Choose a policy explicitly if you need a different transcript:

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-entryNot supported. SQLite keeps I/O; a process restart fail-closes leftover live rows whose owning process is gone (abandonLiveRunsLostOnRestart). Continue from a step via Retry.
type WorkflowContext = {
readonly workflowRunId: string;
readonly parentWorkflowRunId: string | null;
readonly rootWorkflowRunId: string;
readonly stepId: string | null;
readonly stepPath: string[];
readonly parentStepId: string | null;
readonly signal: AbortSignal;
step: StepFn;
memoryScopeWithSuffix: (suffix: string) => string;
runLocalScope: (suffix: string) => string;
emit(name: string, payload?: unknown): void;
setTitle(title: string): Promise<void>;
requestApproval(payload?: unknown): Promise<void>;
};

memoryScopeWithSuffix(suffix) → `${rootWorkflowRunId}:${suffix}`. runLocalScope(suffix) → `${workflowRunId}:${suffix}` (see Nested and Isolated Runs). stepId is null for the workflow body whether or not a parent run exists; use parentWorkflowRunId === null for “I am the entry-point (or isolated) invocation.”

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 (workflowRunId on the context). Nested runs have their own row, so a nested setTitle retitles the child, not the parent. Gate titles on ctx.parentWorkflowRunId === null when a phase should title itself only when started standalone.

export const literatureReview = adl.createWorkflow({
id: "literature-review",
inputSchema: z.object({ topic: z.string() }),
async run(input, ctx) {
await ctx.setTitle(`Literature review: ${input.topic}`);
// ...
},
});

Pass a second argument on workflow.run (or tags on agent.run) to label that invocation. The CLI has no --tags flag; the inspection UI start dialog does not collect tags either. The UI still shows whatever was recorded — caller tags plus automatic provenance — in a Tags footer on the workflow-run and agent-conversation inspectors. There is no run-list filter.

await searchPapers.run({ topic: "CRISPR" }, { tags: ["dataset:qa-v1"] });
await researcher.run({ user: "Summarize CRISPR", tags: ["dataset:qa-v1"] });

Every run also records which project code produced it, unless you already passed a tag with the same prefix:

  • version:<value> when createAdlRuntime({ version }) is a string
  • otherwise commit:<id> from jj @ first, then git HEAD, with +dirty if the tree has uncommitted work

createAdlRuntime({ version: false }) skips that lookup (createTestRuntime does this). See Runtime.

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",
inputSchema: 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.

await ctx.requestApproval(payload?) parks the run until a person decides. It records an effect_intent (kind: "approval"), persists a suspend record on the workflow store, emits workflow_suspended, and sets the run status to suspended. The inspection UI shows the pending approval with Allow / Deny; code can call store.resolveSuspend(handleId, { action: "allow" }) (find the handle with store.listPendingSuspends({ workflowRunId })). Allow emits workflow_resumed and returns; deny throws an AdlError with the reason, which fails the step or run unless you catch it. Approvals cannot be rewritten.

export const publish = adl.createWorkflow({
id: "publish",
run: async (input, ctx) => {
const draft = await ctx.step("draft", async () => writeDraft(input));
await ctx.requestApproval({ message: "Publish this draft?", draft });
return ctx.step("publish", async () => publishDraft(draft));
},
});

Tool calls gated by adl.services.effectGate and the inspection UI’s debugger (Pause / Continue / Step) park on the same suspend records. A parked run lives in the process that started it. If that process exits, the run cannot continue: the next inspector start marks it failed. Cancelling a parked run closes its pending suspend.

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