Skip to content

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 an AbortSignal that aborts when the run ends, is aborted or times out, or when a <Catch> fallback replaces the component. Pass it to fetch, the AI SDK, or anything else that can stop.
  • One execution per id. Components that call useStep with the same id and cache key share one call to fn. See Step identity.
  • A slot. It counts toward the run's concurrency limit while fn runs, and is queued while 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>, whose retry runs fn again.

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