Agent Development Kit · Experimental

Dynamic workflows, and a file Claude Code already runs

A workflow here is not a new runtime. It is an ordinary step, run by app.run, returning an ordinary result. On top of that sits one optional loader: @animahealth/adk/workflow takes a .workflow.js file written for the Claude Code Workflow tool and runs its body on this runtime, unchanged.

Status · experimental subpath Cells · no key required Source · src/workflow/

Read this first

Experimental: this subpath can change without notice

@animahealth/adk/workflow is the only subpath in the package that carries foreign vocabulary — runWorkflowFile, TierModelMap, NodeRunner. Names, option shapes, and error types here can change in any release, without a deprecation window. Nothing in the ADK core imports it, and it is not part of the surface the package promises to hold still. What pre-1.0 means, and which surfaces carry which promise, is owned by the package README’s Stability section — read that before you build something load-bearing on this page.

Two consequences for this chapter. The loader reads files from disk, so it is Node-only and cannot run in this page: every block that mentions runWorkflowFile is static code, checked by hand against src/workflow/index.ts. What is runnable here is the piece that lives in the core — ctx.note — and those cells need no key, because a step never calls a model.

Step 1

A workflow is a step, and nothing else

There is no app.workflow, no WorkflowResult, no runWorkflow. A workflow is an app.step whose body orchestrates other runs with plain async/await; you run it with app.run and get a RunResult. The three additions that make that comfortable — app.ask, fanout, and ctx.note — are general core primitives, useful in any step. Many agents covers the first two; this chapter owns ctx.note and the file loader.

Here is a step that emits the three annotation shapes the loader emits, reads them back, and returns a value. No model is involved, so this runs on nothing but the shipped runtime.

import { adk, isAnnotationEvent } from '@animahealth/adk'

const app = adk()

const build = app.step({
  name: 'build',
  execute: (ctx) => {
    ctx.note('Plan', { kind: 'phase' })
    ctx.note('node:impl:renderer', {
      kind: 'mark',
      label: 'impl:renderer',
      data: { phase: 'Implement' },
    })
    ctx.note('plan returned 3 files')
    ctx.output({ files: ['renderer.ts'] })
  },
})

const buildRun = await app.run(build, 'go')

// Reading them back is a filter over the same ledger — there is no second channel.
const annotations = buildRun.session.events.filter(isAnnotationEvent).map((e) => ({
  kind: e.kind,
  message: e.message,
  label: e.label,
  phase: e.data?.phase,
}))

const ledger = { events: buildRun.session.events.map((e) => e.type), annotations }

ledger

Read events first. The input landed as a user event, an invocation opened, three annotations followed, the invocation closed. No model_start, because a step never calls a model — which is why this cell needs no key. Then annotations: the same ledger, narrowed with isAnnotationEvent, with the three shapes the step emitted. The step's ctx.output(value) is what RunResult.output.value carries — which is also how a workflow file's return reaches the caller.

Step 2

ctx.note is the whole progress surface

Long orchestrations need to say where they are without inventing a logging channel. ctx.note(message, opts?) appends one AnnotationEvent to the same append-only ledger everything else writes to. It is the only event kind these workflows added, it is general (any step may call it), and it streams with every other StreamEvent — so a live UI, a CI log, and a stored session all read the same thing.

Field Type Meaning
type 'annotation' Narrow the union with isAnnotationEvent.
kind 'phase' | 'log' | 'mark' Defaults to 'log' when opts.kind is omitted.
message string? The first argument to note.
label string? A short identifier — a phase title, a node id.
data Record<string, unknown>? Structured payload for consumers that render more than a line of text.

Timestamp and invocation id are stamped by the ledger, not by you. Pulling annotations back out is the filter in the cell above — events.filter(isAnnotationEvent), the same way you pull anything else out of a session.

Three kinds, three uses. phase marks a boundary a reader can scan for; log is a line of narration; mark is a checkpoint carrying structure in data. The loader in the next sections emits exactly these three shapes and invents nothing else.

Step 3

What a workflow file looks like

A workflow file is the authoring surface Claude Code's Workflow tool already uses: one .js file, a meta export, then a body that calls four globals it never imports — agent, parallel, phase, log. The loader binds those globals before the body's first statement runs, so the top-level phase('Plan') below resolves rather than throwing.

// build-renderer.workflow.js
export const meta = {
  name: 'build-renderer',
  description: 'Plan, implement in parallel, then verify the renderer.',
  whenToUse: 'After the contract changes and the renderer must be rebuilt.',
  phases: [
    { title: 'Plan', detail: 'Agree the module layout', model: 'opus' },
    { title: 'Implement', detail: 'One node per module' },
    { title: 'Verify', detail: 'Build and test' },
  ],
}

const PLAN_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  properties: {
    modules: { type: 'array', items: { type: 'string' } },
    risks: { type: 'array', items: { type: 'string' } },
  },
  required: ['modules'],
}

const FILES_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  properties: { files: { type: 'array', items: { type: 'string' } } },
  required: ['files'],
}

phase('Plan')
const plan = await agent('Plan the renderer modules against the contract.', {
  label: 'plan',
  model: 'opus',
  schema: PLAN_SCHEMA,
})
if (!plan) return { aborted: 'planning produced no verdict' }
log(`Plan: ${plan.modules.length} modules`)

phase('Implement')
const impls = await parallel(
  plan.modules.map((module) => () =>
    agent(`Implement ${module}.`, {
      label: `impl:${module}`,
      phase: 'Implement',
      model: 'sonnet',
      schema: FILES_SCHEMA,
    }),
  ),
)

const files = impls.filter(Boolean).flatMap((r) => r.files)
log(`Implemented ${files.length} files`)

return { modules: plan.modules, files }

Four things in that file are load-bearing, and each is a contract the loader keeps. meta is a pure object literal. Top-level await and top-level return both work. A failed agent() resolves to null rather than throwing, which is why if (!plan) and .filter(Boolean) are the idioms. And model is a tier string — the file never names a provider.

Step 4

Running one: runWorkflowFile

The file is not passed to app.run. runWorkflowFile is the bridge: it reads the source, parses meta, wraps the body in an app.step named meta.name, and runs it. The tier map is required, and it is where the deployment decision lives — the file says 'opus', you say what 'opus' is.

import { adk } from '@animahealth/adk'
import { claude } from '@animahealth/adk/claude'
import { openai } from '@animahealth/adk/openai'
import { runWorkflowFile } from '@animahealth/adk/workflow'

const app = adk()

const result = await runWorkflowFile('workflows/build-renderer.workflow.js', {
  app,
  models: {
    default: openai('gpt-5.6-luna'),                 // used when agent() omits model
    byTier: {
      sonnet: openai('gpt-5.6-luna'),
      opus: claude('claude-opus-4-5', {}),           // claude() takes a required config object
    },
  },
  // node: a NodeRunner — see below. Omitted, agent() is a no-tools app.ask.
})

result.status          // 'completed'
result.output?.value   // { modules: [...], files: [...] } — the file's return value
In the file Binds to Result contract
agent(prompt, opts?) the node runner — by default app.ask The schema-validated object (or text), unwrapped — not a RunResult. Any failure becomes null; it never throws out of the body.
parallel(thunks) fanout(thunks) Results in input order; a failed thunk is null; the call never rejects. Concurrency is fanout's default cap, min(16, cores - 2) and never below one — the loader passes no limit of its own.
phase(title) ctx.note(title, { kind: 'phase' }) One annotation with kind: 'phase' and the title as its message.
log(message) ctx.note(message) One annotation with the default kind: 'log'.
label / phase on agent() ctx.note('node:<label>', { kind: 'mark', label, data }) A node-level mark carrying label and data.phase. It does not emit a second phase marker: the count of phase events equals the count of phase() calls.

Four failures stop the run before any node executes: a computed (non-literal) meta; a tier a file names that the map does not define; a deferred feature; and the v2 options resume, background, runId. There is no substitution anywhere in that list — an unmapped tier does not silently fall back to default. An omitted model uses default; a present but unmapped one is an UnmappedTierError naming both the tier and the tiers you did define.

Because meta is required to be a pure literal, a host can read it without running anything — parseWorkflowMeta(source) evaluates only the object literal, in an empty VM context, and returns { name, description, whenToUse?, phases? }. That is how a workflow file can be listed, indexed, or shown in a picker before anyone agrees to execute it. Unknown extra keys in meta are ignored, not rejected.

Step 5

Exactly where the compatibility stops

The headline is real but bounded: a .workflow.js written for Claude Code runs on this runtime with zero body edits, provided it stays inside the subset below. That subset was not guessed — it is what the Serenity build attractors actually use. Everything outside it raises UnsupportedCCFeatureError, whose message names the specific feature. Nothing is silently ignored, and nothing is approximated.

Supported Rejected, by name
meta with name, description, whenToUse?, phases?: [{ title, detail?, model? }] a meta that is not a pure object literal
agent, parallel, phase, log pipeline, nested workflow(), args, budget
agent() options label, phase, model, schema — and only those four isolation, agentType, retries, timeoutMs, and any option key the loader does not recognize
tier strings resolved through your map, plus a default a tier the map does not define
JSON Schema literals on agent({ schema }), top-level await, top-level return resume, background, runId on the loader itself (durable resume and detached execution are not built)

budget deserves its own sentence, because approximating it would be worse than refusing it: cross-run token accounting does not exist here, so budget.remaining throws rather than returning a number a workflow would then trust.

Three limits that are not features. They follow from how the loader executes a file, and a workflow file that trips them will not run.

The file is text, not a module. The loader reads the source, removes the export const meta = … declaration, strips remaining export keywords, and compiles the rest as an async function body whose parameters are the four globals. That is what makes top-level await and top-level return work — and it is also why a static import statement in a workflow file cannot work. None of the repo's workflow files use one.

Only meta is sandboxed. The literal is evaluated in an empty VM context. The body is not: it runs in-process with the loader's own globals and full Node access. Run a workflow file the way you would run any script you are about to execute — this is not a sandbox for untrusted code.

The node runner gets no signal. NodeRunner declares a third signal parameter, and the loader passes undefined for it on every call. Aborting the run still settles it, but a node runner that needs to cancel in-flight work must arrange its own signal.

Step 6

The node seam: one agent() call, two kinds of agent

Claude Code has one agent(). The ADK deliberately separates a no-tools LLM call from a coding agent that edits files and runs commands. The node option is where that difference is made explicit — one config, and the file body stays identical.

type NodeRunner = (
  prompt: string,
  opts: CCAgentOpts,        // { label?, phase?, model?, schema? }
  signal?: AbortSignal,     // always undefined from this loader
) => Promise<unknown | null>

Omit node and every agent() call becomes app.ask: a one-shot call on a fresh session, with no tools, returning the schema-validated value — or null if it fails, including after its schema retries are exhausted. That is the right node for planning, judging, extraction, and verdicts.

Supply a node and the same call goes wherever you send it. A build workflow sends it to a coding agent over a provisioned workspace, so the file's agent('implement X') mutates a workspace rather than returning prose about one. That runner, its workspace lifecycle, and how its work is scored on the environment delta belong to Coding agents. Returning null from your runner is how you signal a per-node failure without breaking the file's filter(Boolean) idiom.

Step 7

JSON Schema in the file, Zod at the boundary

A workflow file cannot import Zod, so agent({ schema }) carries a JSON Schema object literal. The loader converts it statically, and the conversion is deliberately narrow: widening a constraint would let a node return a shape the file then dereferences.

  • required is preserved exactly as written — a strict subset. Properties not listed become .optional(). It is never widened to every key, never emptied.
  • additionalProperties: false becomes a strict object that rejects extra keys.
  • enum membership is enforced whatever the declared type; a non-string enum becomes a union of literals.
  • string, number, integer, boolean, null, and array element types are enforced, never collapsed to z.any(). Nested objects inside arrays keep their own required.
  • A node with no type and no enum stays unknown — the converter does not fabricate a constraint it was not given.

What reaches the file is the parsed value, unwrapped. A validation failure the node cannot recover from is a null, which the file is expected to guard — the same contract every other node failure has. Structured output covers how schema-shaped output behaves on the native surface, where you write the Zod yourself.