Description
The policy documentation for tool approvals says:
reason is optional and is surfaced back to the model (for deny) or to the human
approver (for requires-approval).
The second half of that does not happen. In @ai-sdk/policy-opa, the normalizer that turns
a policy decision into an approval status reads the reason off the record and then passes it
on three of four branches:
switch (record.decision) {
case "allow": return withReason("approved", reason);
case "deny": return withReason("denied", reason);
case "requires-approval": return { type: "user-approval" }; // reason dropped here
case "not-applicable": return { type: "not-applicable" };
}
The one branch that discards it is the one that goes to a person. The doc comment on that
same function confirms the mapping is intended: requires-approval "maps to approved /
denied / user-approval respectively".
There is also nowhere downstream to put it, so this is not a one line oversight.
user-approval is the single status the type forbids a reason on:
type ToolApprovalStatus =
| { type: 'approved'; reason?: string }
| { type: 'denied'; reason?: string }
| { type: 'user-approval'; reason?: never } // the human approver's case
| { type: 'not-applicable'; reason?: never };
And ai matches, in both the generate path and the stream path. Simplified from the build
output of ai@7.0.79, with the surrounding assignments removed:
case 'user-approval':
// what the person is shown. no reason field exists on it
{ type: 'tool-approval-request', approvalId, toolCall, signature? }
case 'approved':
// what no person reads. carries the reason
{ type: 'tool-approval-response', approvalId, toolCall, approved: true,
reason: toolApprovalStatus.reason, providerExecuted }
The obvious objection, and why I do not think it holds. approved and denied carry
the reason on the approval response, and a pending user-approval has no response yet, so
it can look as though the reason has nowhere to go by construction. But ToolApprovalRequest
has no field for it either, and the request is the only thing that reaches the person before
they decide. So the single path where the documentation promises a reason is the single path
with nowhere to put one, at the policy layer, in the type, and in the request.
By the time execution reaches this branch the policy has already worked out why the call
needs a human. That explanation is discarded exactly where a human is the audience.
I ran into this building an approval interface for the SDK. At the moment somebody has to
decide, the application holds the tool name, the raw input and an id, and has nothing it can
put in front of them.
Happy to open a PR for this on its own if that would help.
Related: #15733 asks for per-call context on approval requests more broadly. This is the
narrower half of it and looks independently fixable.
Reproduction
Not a runtime reproduction. This is a contradiction between the documentation and the
published code, so it reproduces by reading them.
@ai-sdk/policy-opa@1.0.79 normalizes a policy decision into an approval status. The
allow and deny branches call withReason(...). The requires-approval branch
returns { type: 'user-approval' } and drops the reason.
ToolApprovalStatus in ai@7.0.79 permits reason?: string on approved and denied,
and reason?: never on user-approval, so there is nowhere to carry it even if the
branch above kept it.
- In the
ai build output the user-approval branch constructs a tool-approval-request
as { type, approvalId, toolCall, signature? }. There is no reason field on the request.
- The policy tool approvals documentation states that
reason is surfaced to the human
approver for requires-approval.
Happy to turn this into a failing type test if that is more useful than prose.
AI SDK Version
- ai: 7.0.79
- @ai-sdk/provider-utils: 5.0.30
- @ai-sdk/react: 4.0.82
- @ai-sdk/policy-opa: 1.0.79
Read off the published type definitions and build output on 25 August 2026.
Code of Conduct
Description
The policy documentation for tool approvals says:
The second half of that does not happen. In
@ai-sdk/policy-opa, the normalizer that turnsa policy decision into an approval status reads the reason off the record and then passes it
on three of four branches:
The one branch that discards it is the one that goes to a person. The doc comment on that
same function confirms the mapping is intended:
requires-approval"maps toapproved/denied/user-approvalrespectively".There is also nowhere downstream to put it, so this is not a one line oversight.
user-approvalis the single status the type forbids a reason on:And
aimatches, in both the generate path and the stream path. Simplified from the buildoutput of
ai@7.0.79, with the surrounding assignments removed:The obvious objection, and why I do not think it holds.
approvedanddeniedcarrythe reason on the approval response, and a pending
user-approvalhas no response yet, soit can look as though the reason has nowhere to go by construction. But
ToolApprovalRequesthas no field for it either, and the request is the only thing that reaches the person before
they decide. So the single path where the documentation promises a reason is the single path
with nowhere to put one, at the policy layer, in the type, and in the request.
By the time execution reaches this branch the policy has already worked out why the call
needs a human. That explanation is discarded exactly where a human is the audience.
I ran into this building an approval interface for the SDK. At the moment somebody has to
decide, the application holds the tool name, the raw input and an id, and has nothing it can
put in front of them.
Happy to open a PR for this on its own if that would help.
Related: #15733 asks for per-call context on approval requests more broadly. This is the
narrower half of it and looks independently fixable.
Reproduction
Not a runtime reproduction. This is a contradiction between the documentation and the
published code, so it reproduces by reading them.
@ai-sdk/policy-opa@1.0.79normalizes a policy decision into an approval status. Theallowanddenybranches callwithReason(...). Therequires-approvalbranchreturns
{ type: 'user-approval' }and drops the reason.ToolApprovalStatusinai@7.0.79permitsreason?: stringonapprovedanddenied,and
reason?: neveronuser-approval, so there is nowhere to carry it even if thebranch above kept it.
aibuild output theuser-approvalbranch constructs atool-approval-requestas
{ type, approvalId, toolCall, signature? }. There is no reason field on the request.reasonis surfaced to the humanapprover for
requires-approval.Happy to turn this into a failing type test if that is more useful than prose.
AI SDK Version
Read off the published type definitions and build output on 25 August 2026.
Code of Conduct