Skip to content

Human-in-the-loop approvals

Some tools must wait for human review: promoting a build, approving a PR, spending money. Mark those tools needsApproval and the host parks the in-flight call until a person approves or denies it, from the playground, over HTTP, or with Slack buttons. The turn stays running; nothing executes until someone decides.

Gate a tool

Mark the tool with needsApproval and export it like any other tool.

ts
import { defineTool } from "@cursor/july/tools";
import { z } from "zod";

export default defineTool({
  description: "Promote a verified build to production.",
  needsApproval: true,
  inputSchema: z.object({
    service: z.string(),
    buildId: z.string(),
  }),
  async execute({ service, buildId }) {
    return {
      promoted: true,
      service,
      buildId,
      environment: "production",
    };
  },
});

needsApproval also takes a predicate over the validated input, so you can gate only the dangerous shapes:

ts
needsApproval: (input) => input.amountUsd > 100,

What happens to a gated call

When the model calls a needsApproval tool, the call parks and the session's stream emits action.approval_requested with the callId, tool name, and arguments. The turn stays running and waits. When a person resolves it, the stream emits action.approval_resolved with the decision and, where known, who decided. On approval, execute runs and action.result follows exactly as if the tool had run immediately. On denial, the model receives the denial as the tool result and continues the turn. It can explain, adjust, or try something else.

Approvals exist for execution: "server" tools on the local runtime only. Parked calls do not survive a host restart: pending approvals left after a crash resolve as interrupted, and you re-run the turn.

Resolve from the playground

Parked calls render Approve / Deny buttons inline in the conversation. Nothing to configure.

Resolve over HTTP

List and resolve programmatically. The routes run the same auth chain as the rest of the session API, and the caller must be the session owner:

bash
curl http://127.0.0.1:3000/<slug>/v1/session/ses_.../approvals
# {"approvals":[{"callId":"...","toolName":"approve_pr","args":{...}}]}

curl -X POST http://127.0.0.1:3000/<slug>/v1/session/ses_.../approvals/<callId> \
  -H 'content-type: application/json' \
  -d '{"decision":"approve"}'   # or "deny"

Resolve from Slack

Set toolApprovals: true on the Slack channel and the pack posts Block Kit Approve/Deny cards and routes the button clicks back to the parked call:

ts
import { slackChannel } from "@cursor/july/channels/slack";

export default slackChannel({
  toolApprovals: true,
});

Approval cards need interactivity on the Slack app. Cards show redacted, truncated arguments because of Block Kit size limits. Execution still uses the full validated input, so review sensitive tools in the playground when the arguments may exceed the card. Details in the Slack guide.

In serve --dev, the playground can also list Slack sessions and resolve their parked tools (the audit trail records the HTTP caller). That bridge does not open cross-owner approval for HTTP sessions, and production and bearer-auth hosts stay strict: Slack approvals must come from Slack interactivity or a matching principal.

Design principles for approvals

Park/resume is independent of delivery: needsApproval works with no Slack channel at all, because the playground and HTTP surfaces are always there.

Prefer approval gates over trust in instructions. "Only call this tool when…" is a suggestion; needsApproval is a guarantee. And pair approvals with dry-run defaults for destructive integrations: post real PR reviews only behind an explicit env flag like MY_AGENT_LIVE=1, no matter who approved.

Deterministic tool calls (agent-sdk call, POST /v1/tools/:name) execute directly without parking. There the human is the caller. Approval parking applies to model-initiated calls.

What's next

Continue with these pages: