Custom steps with useStep
useStep makes ordinary async work part of the workflow runtime. It's not another way to call a model.
When to use it
Use <Step> when the unit of work is a model or agent call. Use useStep when it's ordinary async code, such as fetching data, running a command, reading a service or waiting on a job, and you want it to take part in the run like a step does: its cancellation, concurrency limit, retries and snapshots.
A useStep runs once per id, and the component that calls it suspends until it settles:
import { openai } from "june/sdk/openai";
import { Agent, Step, useStep } from "june";
function Issue({ number }: { number: number }) {
const issue = useStep(
`issue-${number}`,
(signal) =>
fetch(`https://api.github.com/repos/johno/june/issues/${number}`, {
signal,
}).then((response) => response.json()),
{ cacheKey: "issue-v1" },
);
return <Step id={`triage-${number}`} prompt={`Triage: ${issue.title}`} />;
}
export default function Triage() {
return (
<Agent model={openai("gpt-5-mini")}>
<Issue number={12} />
</Agent>
);
}
useStep(id, fn, { cacheKey }) returns fn's result. Until it settles, the component suspends, so nothing after the hook runs and nothing it renders exists yet. The fetch gets the run's AbortSignal, so aborting the run stops it, and it takes one of the run's concurrency slots while it's in flight, like a <Step>.
For work that runs somewhere else and outlives your process, such as a training job, a step that waits on it isn't enough on its own: see Jobs.
What it gets
- A signal.
fn's first argument is anAbortSignalthat aborts when the run ends, is aborted or times out, or when a<Catch>fallback replaces the component. Pass it tofetch, the AI SDK, or anything else that can stop. - One execution per id. Components that call
useStepwith the same id and cache key share one call tofn. See Step identity. - A slot. It counts toward the run's concurrency limit while
fnruns, and isqueuedwhile it waits for one. - A snapshot entry. Its result is saved in
snapshot()and reused on resume when the id and cache key match. - Retries. A failure goes to the closest
<Catch>, whoseretryrunsfnagain.
A useStep has no trace node of its own. The component that calls it renders whatever it likes, such as the <Step> above. It still counts toward the run: its result is in steps, and the usage it reports is in the run's usage.
Results
Return a value that survives JSON.stringify if you save snapshots: a resumed step gets back whatever was saved. undefined doesn't survive (a snapshot entry without a result is rejected), so return null for a step that only has an effect.
Cache keys
June can't see what fn does, so its cache key is the cacheKey you give it, "" by default. Change it when fn changes, so a snapshot from an earlier run isn't reused. Rendering the same id with two different keys fails the run.
useStep("issue-12", fetchIssue) // key ""
useStep("issue-12", fetchIssue, { cacheKey: "issue-v2" }) // a new version: snapshots of the old one don't match
Waiting
A run ends once no step is in flight and React has nothing left to render, so a timer outside a step doesn't hold it open. Make a wait a useStep, as the retry example does. A waiting step holds a concurrency slot like any other.
Reporting model calls
You only need this when fn itself calls a model. A <Step>'s calls go through June, which records their usage; a call fn makes with generateText doesn't, so June can't see it, and the run's usage would leave it out. Report it, and it counts like a <Step>'s: fn's second argument has reportCall({ label, model, usage, finishReason, durationMs }). Reported calls are listed in the step's calls, and their usage counts toward the step's and the run's usage.
import { openai } from "june/sdk/openai";
import { generateText } from "ai";
import { Step, useStep } from "june";
function Summary({ text }: { text: string }) {
const summary = useStep(
"summary",
async (signal, step) => {
const started = performance.now();
const result = await generateText({
model: openai("gpt-5-mini"),
prompt: `Summarize: ${text}`,
abortSignal: signal,
});
step.reportCall({
label: "summary",
model: result.finalStep.model,
usage: result.totalUsage,
finishReason: result.finishReason,
durationMs: performance.now() - started,
});
return result.text;
},
{ cacheKey: "summary-v1" },
);
return <Step id="title" prompt={`Title this summary: ${summary}`} />;
}
june/copy's <Write> reports every call a voice makes this way.
To use the closest <Agent>'s settings in fn, call useAgent() during render and pass the agent in.
Next
- Running workflows: start, cancel and time out a run.
- Recovery: what happens when a step fails.