Composition and data flow
Nesting is dependency. A step's result is passed to what it renders, so its children can't exist, and can't run, until it finishes. There's no graph to declare: the component tree is the dependency graph, and ordinary JavaScript decides what renders next.
Passing results down
A step's function child receives its result. Anything rendered there depends on the step:
// release-notes.tsx
import { openai } from "june/sdk/openai";
import type { LanguageModel } from "ai";
import { Agent, Step } from "june";
export default function ReleaseNotes({
commits,
model = openai("gpt-5-mini"),
}: {
commits: string[];
model?: LanguageModel;
}) {
return (
<Agent model={model} instructions="You write release notes for developers.">
<Step
id="changes"
prompt={`Group these commits into user-facing changes:\n${commits.join("\n")}`}
>
{(changes) => (
<Step
id="notes"
prompt={`Write release notes from these changes:\n${changes}`}
/>
)}
</Step>
</Agent>
);
}
june run release-notes.tsx --input '{"commits":["Add --json to june run","Fix a crash on empty input"]}'
changes runs first. notes doesn't exist until changes finishes; then it renders with the result in its prompt and runs. The edge changes → notes exists only because notes is rendered from changes' result; nothing else declares it.
- Results flow down. A result reaches the steps rendered inside its step's function child, through props and closures like any React value. A step can't read a sibling's result; to use several results together, see fan-in.
- Results flow out.
run()resolves withsteps, every finished step's result keyed by id, andjune run --jsonprints it assteps, next tousageandtree. - Nesting is the only dependency. Two steps that aren't nested don't wait for each other. What if two children are independent? Mark them with
<Parallel>, and the run's concurrency limit decides how many execute at once.
Branching
A branch is JavaScript. Only what renders runs:
// pr-review.tsx
import { openai } from "june/sdk/openai";
import { Output, jsonSchema, type LanguageModel } from "ai";
import { Agent, Step } from "june";
const verdict = Output.object({
schema: jsonSchema<{ score: number; reason: string }>({
type: "object",
properties: {
score: { type: "number", description: "1 to 10" },
reason: { type: "string" },
},
required: ["score", "reason"],
additionalProperties: false,
}),
});
export default function PullRequestReview({
diff,
model = openai("gpt-5-mini"),
}: {
diff: string;
model?: LanguageModel;
}) {
return (
<Agent model={model} instructions="You are a senior code reviewer.">
<Step
id="score"
prompt={`Score this diff from 1 to 10:\n${diff}`}
output={verdict}
>
{({ score, reason }) =>
score < 7 ? (
<Step
id="changes"
prompt={`Ask for changes to this diff. Your concern: ${reason}\n${diff}`}
/>
) : (
<Step
id="approval"
prompt={`Write a short approval. Your reasoning: ${reason}`}
/>
)
}
</Step>
</Agent>
);
}
A score below 7 renders changes; anything else renders approval. The step that isn't rendered never runs, never appears on the trace, and has no entry in steps.
Any JavaScript that decides what to render works: &&, a ternary, an early return, a switch in a helper function, or a .map() over a result. Loops over data are fan-out.
Reusable workflow components
A component that renders steps is a reusable subworkflow. It takes props, can expose its result through a function child of its own, and composes like any other component:
import type { ReactNode } from "react";
import { openai } from "june/sdk/openai";
import { Agent, Step } from "june";
export function Research({
topic,
children,
}: {
topic: string;
children?: (brief: string) => ReactNode;
}) {
return (
<Step id={`sources-${topic}`} prompt={`List the key sources on ${topic}`}>
{(sources) => (
<Step
id={`brief-${topic}`}
prompt={`Write a one-paragraph brief on ${topic} from:\n${sources}`}
>
{children}
</Step>
)}
</Step>
);
}
export default function Brief() {
return (
<Agent model={openai("gpt-5-mini")}>
<Research topic="Suspense">
{(brief) => <Step id="title" prompt={`Title this brief: ${brief}`} />}
</Research>
</Agent>
);
}
Step ids are global to the run, not scoped to the component that renders them. Derive them from the component's props, as Research does with topic, so two instances don't collide. Two instances with the same props render the same ids and definitions, so they share one execution; that's how fan-in works. Step identity has the rules.
A component doesn't need its own <Agent>. Its steps use the closest one where it's rendered, so the caller chooses the model.
Input
A workflow is a component, so its input is its props. june run passes a JSON object as the default export's props with --input, or reads one from a file with --input-file; from code, render it with props:
import type { ReactNode } from "react";
import { run } from "june";
declare function ReleaseNotes(props: { commits: string[] }): ReactNode;
const { steps } = await run(<ReleaseNotes commits={["Add --json"]} />);
The examples in these docs take their model as a prop too, with a default, so a test can pass a mock model without touching the workflow. See Testing.
React, as it is
Everything on this page is ordinary React and JavaScript: props, closures, conditionals, .map() and components. The workflow is a real React tree, rendered by a custom renderer instead of the DOM, so what you know about React carries over. Context, useState, useMemo and useEffect work as usual, and effects run when their part of the tree commits.
One difference from a UI: a run ends once no step is in flight and React has nothing left to render. A timer or a promise that isn't a step doesn't keep it open. Make waiting a step with useStep, as the retry example does.
Next
- Parallelism and concurrency: independent work, dynamic fan-out, fan-in, and the run's concurrency limit.
- Step identity: why ids derived from data matter.