Steps and workflow composition
Connect typed results into a tree that executes in dependency order.
A step owns one piece of work. A workflow gives a connected tree of steps a name and launch input. A parent receives its children’s results, so children run before the parent.
Marbles builds this tree on Workflows. The same definition can occupy a position in more than one tree. Its position determines which results it receives in this workflow.
Inputs and results
A step accepts input and returns a result. .input(schema) validates the
input and supplies its TypeScript type; .output(schema) validates the
result. Return plain JSON data so Marbles can record it and hand it to
another step.
A parent declares the child results it consumes in its input schema. In
the example below, count returns a number and summarize receives that
number as input.count.
Hand a result to the next step
Install zod@4.4.3 in your project. Save this complete module as
.foundry/marbles/summary.ts:
import { step, workflow } from "@foundry.sh/marbles";
import { z } from "zod";
const count = step("count-matching")
.input(z.object({ extension: z.string() }))
.output(z.number())
.do(async ({ input, workspaces }) => {
const files = await workspaces.current.files();
return files.filter((file) => file.path.endsWith(input.extension)).length;
});
const summarize = step("summarize-count")
.input(z.object({ count: z.number(), extension: z.string() }))
.output(z.string())
.do(({ input }) => `${input.count} files end in ${input.extension}`);
workflow("file-summary")
.input(z.object({ extension: z.string() }))
.do(({ input }) =>
summarize({ count: count({}, { extension: input.extension }) }, input)
);
Run marbles roll file-summary --input '{"extension":".ts"}'. The workflow
returns a sentence containing the count.
summarize(children, literalInput) takes children first and literal input
second. count supplies input.count; the literal supplies input.extension.
A key cannot appear in both. The parent’s input schema declares both fields.
Run independent work together
This separate module counts two kinds of file concurrently:
import { step, workflow } from "@foundry.sh/marbles";
import { z } from "zod";
const count = step("count-kind")
.input(z.object({ extension: z.string() }))
.output(z.number())
.do(
async ({ input, workspaces }) =>
(await workspaces.current.files()).filter((file) =>
file.path.endsWith(input.extension)
).length
);
const combine = step("combine-counts")
.input(z.object({ typescript: z.number(), markdown: z.number() }))
.do(({ input }) => input);
workflow(
"project-counts",
combine.parallel({
typescript: count({}, { extension: ".ts" }),
markdown: count({}, { extension: ".md" }),
})
);
marbles roll project-counts returns both counts. Calling combine(...) or
combine.series(...) runs children in key order; .parallel(...) runs them
together. Data flows from children to their parent, never between siblings.
If one parallel child fails, siblings are cancelled and awaited.
Choose what belongs in a step
Make an operation a child step when its result, failure, or progress should be independently visible. Keep ordinary calculations inside a body. A workflow can be a launch root, but cannot be nested as a child in another tree; reuse its steps instead.
.run(input) executes a definition directly, including from inside a
body. Its nested execution does not appear as an independent dashboard run.
Lock a child into the tree when you want that visibility.
There are no race, branching, or loop combinators. You can choose a tree in ordinary TypeScript during setup. Keep the actual commands, file access, and agent turns inside steps. The builder reference lists the exact composition forms.
This choice also determines what Marbles can retry or keep while waiting. Durability explains those rules after the resource chapters. Next: Contexts and bindings explains what setup and step callbacks receive when this tree executes.