Approvals
The AI SDK marks a tool as needsApproval. When a June step reaches that tool call, June pauses the step and exposes the pending request through the live run handle until your app approves or denies it.
The approval contract is the AI SDK's: there's no June tool type or approval setting. June's part is the run: what pauses, what keeps going, and how the answer gets back into the agent loop. There are two ways to answer:
- From the terminal,
june runasks on stdin. - From code,
start()returns a handle with the pending requests andapprove()anddeny().
import { openai } from "june/sdk/openai";
import { tool } from "ai";
import { Agent, Step, Tool, start } from "june";
import { z } from "zod";
const deploy = tool({
description: "Deploy to an environment",
inputSchema: z.object({ env: z.enum(["staging", "prod"]) }),
needsApproval: true,
execute: async ({ env }) => `Deployed to ${env}`,
});
const handle = start(
<Agent model={openai("gpt-5-mini")}>
<Tool name="deploy" value={deploy} />
<Step id="release" prompt="Deploy the latest build to prod" />
</Agent>,
);
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;
<Bash /> needs approval by default and uses this same lifecycle; see where it runs before you turn that off.
june run prints the question on stderr, ? Allow deploy {"env":"prod"} (step release)? [y/N], and reads the answer from stdin. y or yes approves, and anything else denies, including stdin closing. So a script or an agent running june run without a terminal denies every request unless it pipes one answer per request, as in echo y | june run release.tsx.
What happens
- The model calls
deploy. The AI SDK doesn't run it; it ends the model call with an approval request. - The run's status becomes
awaitingApproval, and the request is inhandle.requests:{ id, stepId, toolName, input }. - The step gives up its concurrency slot while it waits, so queued steps can run. Other steps keep running.
handle.approve(id)approves the call andhandle.deny(id, reason)denies it. Once every request from the step is answered, the step queues for a slot again. Then approved tools run and the agent loop continues with their results, while a denied call doesn't run and the model is told it was denied, with your reason. The step finishes like any other.- The status goes back to
runningonce every pending request is answered or withdrawn.
The step's trace node lists the call in toolCalls, with an output once an approved tool has run. A denied call has no output.
run()can't answer requests, so a request makes it reject. Seerun()orstart().approveanddenythrowjune: no pending approval with id <id>for a request that isn't pending: already answered, withdrawn, or from a run that has ended.- A request is withdrawn when its step is aborted: when the run aborts, or when a
<Catch>fallback replaces the step. Checkhandle.requestsbefore answering a request you asked about a while ago. - Several steps can wait at once.
on("awaitingApproval")runs again each time the pending requests change (new ones arrive, or one is answered or withdrawn while others wait), with every pending request, so keep track of the ones you've already asked about. - A request's
inputis the model's tool input, exactly as generated. If it can hold sensitive data, treat wherever you show or log it accordingly; see Security and privacy.
Answering asynchronously
When answers come from somewhere slow, such as a chat message or a web form, ask about each request once and check that it's still pending before answering:
// release.tsx
import type { ReactElement } from "react";
import { openai } from "june/sdk/openai";
import { jsonSchema, tool, type LanguageModel } from "ai";
import { Agent, Step, Tool, start, type ApprovalRequest } from "june";
const deploy = tool({
description: "Deploy the latest build to an environment",
inputSchema: jsonSchema<{ env: "staging" | "prod" }>({
type: "object",
properties: { env: { type: "string", enum: ["staging", "prod"] } },
required: ["env"],
additionalProperties: false,
}),
needsApproval: true,
execute: async ({ env }) => `Deployed to ${env}`,
});
export default function Release({
env = "staging",
model = openai("gpt-5-mini"),
}: {
env?: string;
model?: LanguageModel;
}) {
return (
<Agent model={model}>
<Tool name="deploy" value={deploy} />
<Step
id="release"
prompt={`Deploy the latest build to ${env}, then say what you did.`}
/>
</Agent>
);
}
export function runWithApprovals(
workflow: ReactElement,
confirm: (request: ApprovalRequest) => Promise<boolean>,
) {
const handle = start(workflow);
const asked = new Set<string>();
const pending = (id: string) =>
handle.requests.some((request) => request.id === id);
handle.on("awaitingApproval", ({ requests }) => {
for (const request of requests) {
if (asked.has(request.id)) continue;
asked.add(request.id);
confirm(request).then((approved) => {
// A <Catch> fallback or an abort can withdraw a request while it's asked.
if (!pending(request.id)) return;
if (approved) handle.approve(request.id);
else handle.deny(request.id, "Denied by the operator");
});
}
});
return handle.done;
}
Pass it the workflow and a function that asks, such as runWithApprovals(<Release env="prod" />, (request) => askInSlack(request)). It resolves or rejects like run().
Approvals and resume
A step waiting on an approval hasn't finished, so it isn't in a snapshot. If the process exits while a request is pending, the resumed run runs that step again from the start: the model may call the tool again, and a new request gets a new id. The same goes for a step that was approved but hadn't finished, so its tool can run twice.
Next
- Snapshots and resume: keep finished work across a restart.
- Traces and usage: what the trace shows while a run waits.