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.
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.
-
requiredis preserved exactly as written — a strict subset. Properties not listed become.optional(). It is never widened to every key, never emptied. -
additionalProperties: falsebecomes a strict object that rejects extra keys. -
enummembership is enforced whatever the declaredtype; a non-string enum becomes a union of literals. -
string,number,integer,boolean,null, and array element types are enforced, never collapsed toz.any(). Nested objects inside arrays keep their ownrequired. -
A node with no
typeand noenumstaysunknown— 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.