Skip to content

june

Declarative AI workflows.

June is React for AI workflows: a runtime where the component tree is the program. Nesting creates dependencies, JavaScript controls what renders, independent work can run in parallel, and the rendered tree is the execution trace.

The AI SDK still owns models, tools, structured output and the agent loop. June adds the workflow around them.

Why June

One model call, or one agent looping over its tools, is already solved: that's the AI SDK. The hard part starts when work depends on other work, branches on a result, fans out over a list, fails, pauses for a person, resumes after a restart, or needs to be observed while it runs.

June is that layer, without another graph DSL to learn:

  • Dependencies are nesting: a step's children run once it finishes, with its result.
  • Branches are JavaScript: only what renders runs.
  • Fan-out is .map() inside <Parallel>, bounded by the run's concurrency limit.
  • Failures go to a <Catch> boundary, which can fall back or retry.
  • People answer the AI SDK's tool approvals while the rest of the run keeps going.
  • Restarts reuse the steps that finished, from a snapshot.
  • Observation is the trace: the same tree React renders, as JSON.

React isn't the UI for the workflow. React is the workflow runtime.

Install

bun add june react ai @ai-sdk/openai

june runs on Bun 1.2 or later, and the june CLI requires Bun. react (19.3+) and ai (7.0.137+) are peer dependencies, and models come from any AI SDK provider. The package is ESM only.

june/sdk/openai, june/sdk/anthropic, june/sdk/google and june/sdk/xai give you each provider's models, so import { openai } from "june/sdk/openai" is all a workflow needs. The provider packages are optional: install the ones you use, like @ai-sdk/openai above. Run one you haven't installed with Bun and June's error names the package to add; a bundler reports the missing package when it builds. For anything else a provider offers, such as openai.chat() or provider options, import its @ai-sdk/* package directly. See Package surfaces.

Quickstart

// review.tsx
import { openai } from "june/sdk/openai";
import { Agent, Parallel, Step } from "june";

const diff = await Bun.$`git diff`.text();

export default function Review() {
  return (
    <Agent
      model={openai("gpt-5-mini")}
      instructions="You are a senior code reviewer."
    >
      <Step id="summary" prompt={`Summarize this diff:\n${diff}`}>
        {(summary) => (
          <Parallel>
            <Step
              id="risks"
              prompt={`List the risks in this change:\n${summary}`}
            />
            <Step
              id="tests"
              prompt={`Suggest tests for this change:\n${summary}`}
            />
          </Parallel>
        )}
      </Step>
    </Agent>
  );
}
june doctor review.tsx   # checks Bun and OPENAI_API_KEY
june run review.tsx      # prints progress, then each step's result
june run review.tsx --json > review.json

summary runs first. Once it finishes, risks and tests start together with its result.

Workflow input

A workflow is a component, so its input is its props. june run passes a JSON object as props with --input, or reads one from a file with --input-file (relative to the current directory):

// triage.tsx
export default function Triage({
  repo,
  issues,
}: {
  repo: string;
  issues: number[];
}) {
  return (
    <Agent model={openai("gpt-5-mini")}>
      {issues.map((n) => (
        <Step key={n} id={`triage-${n}`} prompt={`Triage ${repo}#${n}`} />
      ))}
    </Agent>
  );
}
june run triage.tsx --input '{"repo":"johno/june","issues":[12,15]}'
june run triage.tsx --input-file ./triage.json

The input must be a JSON object, and June never prints it. From code, render the component with props: run(<Triage repo="johno/june" issues={[12, 15]} />).

Your program is the workflow

There's no separate graph to define: React and JavaScript already express the control flow. Nesting is a dependency: a step's children render, and run, once it finishes, with its result. The Quickstart's summary → risks, tests is that rule and nothing more.

A conditional is a branch, and only the branch that renders runs:

<Step id="score" prompt="Score this PR from 1 to 10">
  {(score) =>
    Number(score) < 5 ? (
      <Step id="feedback" prompt="Explain what to improve" />
    ) : (
      <Step id="approve" prompt="Write an approval summary" />
    )
  }
</Step>

A .map() is dynamic fan-out. Give each step a stable id derived from its data; the run's concurrency limit keeps a long list from starting every model call at once:

<Parallel>
  {files.map((file) => (
    <Step key={file} id={`review-${file}`} prompt={`Review ${file}`} />
  ))}
</Parallel>

A component is a reusable subworkflow. It holds its own steps, takes props, and composes like any other component:

function Research({ topic }: { topic: string }) {
  return (
    <Step id={`search-${topic}`} prompt={`Find sources about ${topic}`}>
      {(sources) => (
        <Step
          id={`brief-${topic}`}
          prompt={`Write a brief from:\n${sources}`}
        />
      )}
    </Step>
  );
}

<Agent model={openai("gpt-5-mini")}>
  <Parallel>
    <Research topic="Suspense" />
    <Research topic="reconcilers" />
  </Parallel>
</Agent>;

Any async work can be a step with useStep. The component suspends until it resolves, and the result is reused like any other step's:

function Issue({ number }: { number: number }) {
  const issue = useStep(
    `issue-${number}`,
    (signal) =>
      fetch(`https://api.github.com/repos/johno/june/issues/${number}`, {
        signal,
      }).then((r) => r.json()),
    { cacheKey: "issue-v1" },
  );
  return <Step id={`triage-${number}`} prompt={`Triage: ${issue.title}`} />;
}

The API

Export
<Agent>provides the AI SDK's ToolLoopAgent settings to the steps inside it. Agent and Step
<Step>runs the closest agent on a prompt; a function child receives the result. Agent and Step
<Parallel>marks its children as independent. Parallelism and concurrency
<Tool name value>gives the steps in the closest agent an AI SDK tool. Capabilities
<Catch>an error boundary for steps, with a fallback and retry. Recovery
useStep(id, fn, { cacheKey })runs any async function as a step. Custom steps
useAgent()the closest <Agent> as an AI SDK ToolLoopAgent. Agent and Step
run(element, options)renders a workflow and resolves with its results. Running workflows
start(element, options)the same, with a handle for approvals, aborting and snapshots. Running workflows

Running workflows from code

run() resolves with every step's result, the run's token usage, and the final trace tree once nothing is in flight. It rejects with the first error no <Catch> handles.

const { steps, usage, tree } = await run(<Review />, {
  signal, // abort in-flight model calls
  timeout: 120_000, // rejects with a TimeoutError
  concurrency: 8, // the most steps executing at once (the default)
  onUpdate: (tree) => render(tree), // every commit: step status, tool calls, partial text
});

Tools with the AI SDK's needsApproval pause their step until a person answers, so use start(), which returns a handle to approve or deny them. june run asks on stdin. See Approvals.

const handle = start(<Deploy />);

handle.on("awaitingApproval", ({ requests }) => {
  for (const request of requests) {
    if (confirm(`Run ${request.toolName}?`)) handle.approve(request.id);
    else handle.deny(request.id, "Not now");
  }
});

await handle.done;

snapshot() returns every finished step's result and cache key as JSON. Pass it back to skip those steps after a crash or restart. Resume is completed-step reuse, not durable execution: a step that hadn't finished runs again from the start. See Snapshots and resume.

await save(handle.snapshot());
// later
await run(<Deploy />, { snapshot: await load() });

Learn the runtime

The Workflows docs go from the first step to the exact semantics:

  1. Agent and Step: models, tools, prompts, structured output and streaming.
  2. Capabilities: declaring tools in JSX, scope, and reusable bundles.
  3. Composition and data flow: results, input, branches and reusable components.
  4. Parallelism and concurrency: <Parallel>, fan-out, fan-in and the concurrency limit.
  5. Step identity: ids, cache keys, and when a step runs.
  6. Custom steps: useStep for any async work, and reporting its model calls.
  7. Running workflows: run(), start(), cancellation and timeouts.
  8. Recovery: <Catch>, failure and retry.
  9. Approvals: pausing for a person.
  10. Snapshots and resume: reusing finished steps after a restart.
  11. Traces and usage: the trace tree, its fields, and token usage.

Runnable versions of their examples, with tests, are in src/examples/workflows.

Beyond workflows

The same package has optional subpaths for problems you may have later. Start with the runtime; add these when you need them.

  • Chat (june/chat): a workflow runs once and finishes; a chat persists. It turns a model or one of your workflows into a conversation, with feedback on its replies. june chat chat.ts opens one in your terminal, and june ask scripts it.
  • Tools (june/tools): tools June ships, as plain AI SDK tools. <Bash /> lets an agent run shell commands, after you approve each one.
  • Copy and voice (june/copy): builds a voice from a person's real writing and learns from their edits.
  • Evals and training (june/train, june/trainers): turns feedback into evidence. Build datasets, run evals, compare candidates, and fine-tune through a trainer only when evaluation says you need to.

A chat is a file that default exports one:

// chat.ts
import { openai } from "june/sdk/openai";
import { createChat, fileStore } from "june/chat";

export default createChat({
  model: openai("gpt-5-mini"),
  instructions: "You are helping build June.",
  store: fileStore(".june/chat"),
});
june chat chat.ts                         # an interactive chat in your terminal
june ask chat.ts "Explain this package"   # one reply on stdout, for scripts

See also: the chat guides on sessions and stores, turns, responders, context and feedback; learning from chat, which turns sessions into evidence; and local and managed training with trl() and vertex().

What June doesn't do

June is a small runtime on purpose. Reach for something else when you need:

  • Durable execution. Resume reuses finished steps but doesn't checkpoint in-flight work, so a crash mid-step repeats that step. If work must survive the process exactly once, use a durable workflow engine, or a Job for work a backend owns.
  • Storage, queues, schedules and hosting. A run lives in one process, and a snapshot is JSON you save yourself. Your app decides where runs happen and when.
  • A visual builder. Workflows are components, not a graph you draw.
  • Memory, retrieval and MCP abstractions. Use the AI SDK's tools, or your own.
  • Rate limiting. The concurrency limit counts steps, not tokens or requests; use the agent's maxRetries or your provider's limits.

Testing

Use scriptedModel from june/test, so tests don't need API keys or a network. It answers each call with whatever your function returns: text, an object as JSON for a step with an output, or tool calls from callTools(). The agent runs the tools and calls the model again with their results.

import { Agent, Step, Tool, run } from "june";
import { callTools, scriptedModel } from "june/test";

const model = scriptedModel((call) =>
  call.prompt.at(-1)?.role === "user"
    ? callTools({ toolName: "readFile", input: { path: "a.ts" } })
    : "Looks good",
);

const { steps } = await run(
  <Agent model={model}>
    <Tool name="readFile" value={readFile} />
    <Step id="review" prompt="Review a.ts" />
  </Agent>,
);

expect(steps.review).toBe("Looks good");

A scripted model is the AI SDK's MockLanguageModelV4 from ai/test, so model.doGenerateCalls records every call June made, and doStreamCalls those of a step with stream. A reply that throws fails the call. For anything it can't script, such as reasoning or provider metadata, build a MockLanguageModelV4 yourself.