Human-in-the-loop approvals

Dangerous tools pause until the user says yes.

Covers Examples/Features/16-ToolApprovals.swift. You end up with a delete tool that never runs without a confirmation, in both plain calls and chat UIs.

Mark the tool

needsApproval: true pauses the loop before every call. For argument-dependent decisions, pass a closure instead.

let deleteFile = Tool(
  name: "deleteFile",
  description: "Removes a file from disk",
  parameters: ["type": "object",
               "properties": ["path": ["type": "string"]],
               "required": ["path"]],
  needsApproval: true
) { args in
  .string("deleted \(args["path"]?.stringValue ?? "?")")
}

Catch the pause

The loop stops with an approval request instead of executing. Nothing was deleted yet:

let result = try await generateText(
  model: AnthropicModel("claude-sonnet-5"),
  prompt: "Clean up /tmp/scratch.txt",
  tools: [deleteFile]
)

for request in result.steps.last?.approvalRequests ?? [] {
  print("wants to run \(request.call.name) with \(request.call.arguments)")
}

Resume with the decision

Append the user's answer to the history and call again. Approved calls execute; denied ones surface to the model as denials:

var messages = result.messages
messages.append(Message(role: .tool, content: [
  .toolApprovalResponse(ToolApprovalResponse(
    approvalID: request.approvalID,
    toolCallID: request.call.id,
    approved: true
  ))
]))

let resumed = try await generateText(
  model: AnthropicModel("claude-sonnet-5"),
  messages: messages,
  tools: [deleteFile]
)
print(resumed.text)

In a chat UI it's one call

ChatSession surfaces the pause as a tool part in approvalRequested state. Show your confirmation UI, then answer:

for part in chat.messages.last?.parts ?? [] {
  guard case .tool(let tool) = part,
        tool.state == .approvalRequested,
        let approval = tool.approval else { continue }
  chat.addToolApprovalResponse(approvalID: approval.id, approved: true)
}

Final code

ApprovedDelete.swift
import AI

let deleteFile = Tool(
  name: "deleteFile",
  description: "Removes a file from disk",
  parameters: ["type": "object",
               "properties": ["path": ["type": "string"]],
               "required": ["path"]],
  needsApproval: true
) { args in
  .string("deleted \(args["path"]?.stringValue ?? "?")")
}

func cleanUp() async throws {
  let model = AnthropicModel("claude-sonnet-5")

  let result = try await generateText(
    model: model,
    prompt: "Clean up /tmp/scratch.txt",
    tools: [deleteFile]
  )

  for request in result.steps.last?.approvalRequests ?? [] {
    let userSaidYes = await confirmWithUser(request.call)

    var messages = result.messages
    messages.append(Message(role: .tool, content: [
      .toolApprovalResponse(ToolApprovalResponse(
        approvalID: request.approvalID,
        toolCallID: request.call.id,
        approved: userSaidYes
      ))
    ]))

    let resumed = try await generateText(
      model: model,
      messages: messages,
      tools: [deleteFile]
    )
    print(resumed.text)
  }
}

Client-side tools work the same way with one difference: there's no executor at all, so the app supplies the result instead of a yes or no:

chat.addToolResult(toolCallID: tool.toolCallID, result: ["photoID": "IMG_0042"])