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.
Two Composition Primitives
Section titled “Two Composition Primitives”ctx.step | Nested workflow | |
|---|---|---|
| Purpose | Observability span + retry boundary (cached step output) | Reusable unit with typed input → output |
| Contract | Closure captures anything; return value becomes persisted output | Optional Zod inputSchema / outputSchema |
| Reuse | Extract plain TS functions | otherWorkflow.run(input) |
| Tracing | Always creates a step node | Inner workflow defines its own steps |
| Best for | Side 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:
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.
Nested and Isolated Runs
Section titled “Nested and Isolated Runs”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:
| Helper | Formula | Use 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 } | |
|---|---|---|
| Parent | Joins ALS / parentCtx | Ignores parent |
workflowRunId | New id | New id |
| Parent link | parentWorkflowRunId = parent’s id (also on live ctx) | null |
| Root id | rootWorkflowRunId = outermost nest id | Equals this run’s workflowRunId |
| Step link | parentStepId = calling step, or null at workflow root | null |
| Persistence | Own row on WorkflowStore | Own row on WorkflowStore |
| Inspector | Inline under the calling step (or under the workflow row when called at root); expand to load the child’s steps | Own 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.
Input and Output Types
Section titled “Input and Output Types”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.
Step Callback Shape
Section titled “Step Callback Shape”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.
Step Identity
Section titled “Step Identity”| Field | Meaning |
|---|---|
stepId | Unique per invocation (UUID) |
name | First argument to step("…", …) |
key | Optional disambiguator (React-style) |
stepPath | Stable logical path from (name, key) ancestry |
parentStepId | Parent invocation, or null at run root |
Path segments: name when key is omitted, or `${name}:${key}` when keyed.
Step Keys
Section titled “Step Keys”Under a given parent, (name, key) identifies a logical step slot for the whole run:
| Rule | Behavior |
|---|---|
First step("foo", …) with no key | Allowed — default slot for that name |
Second+ step("foo", …) under same parent | key required — throws if omitted |
Duplicate (name, key) | Throw |
Parallel same name | Distinct keys required |
for (const topic of topics) { await ctx.step( "search", async ({ ctx }) => { /* ... */ }, { key: topic }, );}Resumability
Section titled “Resumability”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:
- 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.
- 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. - 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.
Nesting and attempt forests
Section titled “Nesting and attempt forests”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.
Steps Are Atomic Retry Units
Section titled “Steps Are Atomic Retry Units”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).
Path-stable step slots
Section titled “Path-stable step slots”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.
Attempt lineage (retry from a step)
Section titled “Attempt lineage (retry from a step)”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.
Pure vs force
Section titled “Pure vs force”| Option | Scope | Behavior |
|---|---|---|
force: true | This call only | Bypass skip cache for this invocation |
pure: false | Declared on the step | On 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 },);Agents on Retry
Section titled “Agents on Retry”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:
| Policy | Behavior |
|---|---|
| Continue scope | Same memoryScope; model sees prior attempt |
| Fork scope | New suffix per attempt, e.g. ctx.memoryScopeWithSuffix("search:retry-1") |
| Clear scope | Wipe store for that scope before agent.run |
Not in v1
Section titled “Not in v1”| Capability | Status |
|---|---|
| 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 resume | Not a goal |
| Durable crash resume without re-entry | Not 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. |
WorkflowContext
Section titled “WorkflowContext”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.
Run Titles
Section titled “Run Titles”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}`); // ... },});Run Tags
Section titled “Run Tags”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>whencreateAdlRuntime({ version })is a string- otherwise
commit:<id>from jj@first, then git HEAD, with+dirtyif the tree has uncommitted work
createAdlRuntime({ version: false }) skips that lookup (createTestRuntime does this). See Runtime.
Events
Section titled “Events”| Event | Purpose |
|---|---|
step_started | stepId, parentStepId, name, key, path |
step_finished | Terminal success with output |
step_skipped | Reused cached output |
step_failed | Error payload |
custom | Author 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 in Workflows
Section titled “Templates in Workflows”Templates are standalone — no ctx.render. They use Handlebars ({{var}}, {{#each}}, …) after Zod parse. Define with adl.createTemplate in your prompts module:
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() }),});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.
Approvals and Suspends
Section titled “Approvals and Suspends”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.
Parallelism
Section titled “Parallelism”ctx.step returns a Promise. Use Promise.all with distinct keys when running parallel steps with the same name.
Cancellation
Section titled “Cancellation”handle.cancel() aborts ctx.signal for that run. The runtime:
- Rejects in-flight
ctx.stepcallbacks (they can also listen toctx.signal) - Links child
agent.run/agent.streamAbortControllers to the same signal, sostreamTextstops - Emits
workflow_cancelled(andstep_failedfor 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)});