Guard the dangerous calls
Every tool call the model makes passes through your guards before it executes. A guard inspects the call and returns a verdict: run it, ask the user first, or refuse. A refusal is not an error — the model sees the reason and keeps working, so a blocked call becomes a course correction instead of a dead turn.
Write a guard
A guard is one async method. Return .allow, .confirm(reason:estimatedCost:),
or .deny(reason:).
struct DestructiveActionGuard: ToolGuard {
func evaluate(_ call: ToolCall) async -> GuardVerdict {
if call.name == "timeline.delete_clip" {
return .deny(reason: "Deleting clips is disabled in this session.")
}
return .allow
}
}
struct ExportCostGuard: ToolGuard {
func evaluate(_ call: ToolCall) async -> GuardVerdict {
if call.name == "render.export_4k" {
return .confirm(reason: "4K export uses paid render credits.", estimatedCost: Decimal(2.50))
}
return .allow
}
}
Pass guards at makeAgent. They evaluate every tool call in the session —
including the built-in agentkit.* discovery tools, so match on call.name
precisely rather than denying by default.
let agent = try runtime.makeAgent(
provider: .anthropic(apiKey: key),
role: role,
guards: [DestructiveActionGuard(), ExportCostGuard()]
)
How the pipeline decides
Guards run in order, once per tool call, and the verdicts combine with fixed rules:
- a
.denywins immediately — evaluation stops and the call is refused. - a
.confirmis remembered, but evaluation continues — so a deny from a later guard still beats a confirm from an earlier one. - when no guard denies, the first confirm wins: its reason and estimated cost are what the user is asked to approve.
- if every guard allows, the tool executes.
Order matters only among confirms — whichever guard confirms first owns the prompt the user sees. A deny wins from any position.
Require confirmation for specific tools
For a fixed list of tool ids, use the built-in guard instead of writing your own:
guards: [
DestructiveActionGuard(),
RequireConfirmationGuard(toolIds: ["timeline.delete_clip", "billing.charge"]),
]
RequireConfirmationGuard confirms any call whose id is in the set (reason:
"Tool requires confirmation") and allows everything else. It composes with
your own guards under the pipeline rules above — here timeline.delete_clip
is still refused outright, because a deny beats a confirm.
Approve with a handler
A confirmation needs an answer. The first path is a ConfirmationHandler
passed at makeAgent — the session calls it and awaits the verdict:
struct AlertConfirmationHandler: ConfirmationHandler {
func request(_ call: ToolCall, reason: String, estimatedCost: Decimal?) async -> Bool {
await presentConfirmationAlert(named: call.name, reason: reason, cost: estimatedCost)
}
}
let agent = try runtime.makeAgent(
provider: .anthropic(apiKey: key),
role: role,
guards: [ExportCostGuard()],
confirmationHandler: AlertConfirmationHandler()
)
presentConfirmationAlert stands in for however your app asks — an alert, a
sheet, a policy lookup. Return true and the tool runs; return false and
the call is denied.
Approve from your UI
Without a handler, the session parks the request on its observable surface:
pendingConfirmation carries the tool call, the reason, and the estimated
cost, and the turn waits until you answer.
if let pending = agent.pendingConfirmation {
// pending.toolCall, pending.reason, pending.estimatedCost
agent.respondToConfirmation(true) // run the tool
// agent.respondToConfirmation(false) denies it instead
}
This is the SwiftUI-friendly path — bind pendingConfirmation to a sheet or
an inline prompt and call respondToConfirmation from the buttons. The
quickstart shows the full view.
Timeouts deny
confirmationTimeoutSeconds (default 45, set via run limits)
bounds the wait on both paths. When it expires, the call is denied — the tool
never runs, the turn does not fail, and the model is told the user declined.
A timeout of zero or less denies immediately.
What the model sees
| Verdict | The tool | The model sees |
|---|---|---|
.allow |
runs | the tool's result |
.confirm, approved |
runs | the tool's result |
.confirm, declined or timed out |
never runs | Tool denied: user declined confirmation. |
.deny(reason:) |
never runs | Tool denied: <your reason> |
A denial flows back as the tool call's outcome — plain feedback, not an error — and the turn continues. The model can pick another tool, explain why it stopped, or ask the user what to do.
On-device providers
Apple Foundation Models is provider-driven: tools execute inside the provider's
own session, not the app-driven loop these guards gate. Passing guards or a
confirmationHandler to makeAgent for a provider-driven provider throws at
construction — so the misconfiguration surfaces immediately instead of being
silently ignored. Wire them at provider construction instead, via
.appleFoundationModels(guardPipeline:confirmationHandler:undoTransaction:). See
on-device with Apple.
Next
- Undo every turn — roll back what a failed turn changed.
- Bound the turn — caps on calls, round trips, and time.