Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/brave-doors-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@cloudflare/ai-chat": patch
---

Follow-up to #956. Allow `addToolOutput` to work with tools in `approval-requested` and `approval-responded` states, not just `input-available`. Also adds support for `state: "output-error"` and `errorText` fields, enabling custom denial messages when rejecting tool approvals (addresses remaining items from #955).

Additionally, tool approval rejections (`approved: false`) now auto-continue the conversation when `autoContinue` is set, so the LLM sees the denial and can respond naturally (e.g. suggest alternatives).

This enables the Vercel AI SDK recommended pattern for client-side tool denial:

```ts
addToolOutput({
toolCallId: invocation.toolCallId,
state: "output-error",
errorText: "User declined: insufficient permissions"
});
```
15 changes: 10 additions & 5 deletions docs/chat-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -432,22 +432,27 @@ tools: {
**Client:**

```tsx
import { isToolUIPart, getToolName } from "ai";

const { messages, addToolApprovalResponse } = useAgentChat({ agent });

// Render pending approvals from message parts
{
messages.map((msg) =>
msg.parts
.filter(
(part) => part.type === "tool" && part.state === "approval-required"
(part) =>
isToolUIPart(part) &&
"approval" in part &&
part.state === "approval-requested"
)
.map((part) => (
<div key={part.toolCallId}>
<p>Approve {part.toolName}?</p>
<p>Approve {getToolName(part)}?</p>
<button
onClick={() =>
addToolApprovalResponse({
id: part.toolCallId,
id: part.approval?.id,
approved: true
})
}
Expand All @@ -457,7 +462,7 @@ const { messages, addToolApprovalResponse } = useAgentChat({ agent });
<button
onClick={() =>
addToolApprovalResponse({
id: part.toolCallId,
id: part.approval?.id,
approved: false
})
}
Expand All @@ -470,7 +475,7 @@ const { messages, addToolApprovalResponse } = useAgentChat({ agent });
}
```

For more patterns, see [Human in the Loop](./human-in-the-loop.md).
When denied, the tool part transitions to `output-denied`. You can also use `addToolOutput` with `state: "output-error"` for custom denial messages — see [Human in the Loop](./human-in-the-loop.md) for details.

## Custom Request Data

Expand Down
11 changes: 11 additions & 0 deletions docs/client-tools-continuation.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,17 @@ const { addToolApprovalResponse } = useAgentChat({

The flow becomes: LLM calls tool → user approves → client executes → server auto-continues.

If the user denies the tool instead, you can provide a custom error message using `addToolOutput` with `state: "output-error"`:

```tsx
// Deny with a reason instead of generic rejection
addToolOutput({
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: "User declined to share location"
});
```

## Related Docs

- [Chat Agents](./chat-agents.md) — Full `AIChatAgent` and `useAgentChat` reference
Expand Down
28 changes: 28 additions & 0 deletions docs/human-in-the-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,13 @@ function Chat() {
);
}

// Tool was denied
if (part.state === "output-denied") {
return (
<div key={part.toolCallId}>{getToolName(part)}: Denied</div>
);
}

// Tool completed
if (part.state === "output-available") {
return (
Expand All @@ -322,6 +329,27 @@ function Chat() {
}
```

### Custom denial messages with `addToolOutput`

When a user rejects a tool, `addToolApprovalResponse({ id, approved: false })` sets the tool state to `output-denied` with a generic "Tool execution denied." message. If you need to give the LLM a more specific reason for the denial, use `addToolOutput` with `state: "output-error"` instead:

```tsx
const { addToolOutput } = useAgentChat({ agent });

// Reject with a custom error message
addToolOutput({
toolCallId: part.toolCallId,
state: "output-error",
errorText: "User declined: insufficient budget for this quarter"
});
```

This sends a `tool_result` to the LLM with your custom error text, so it can respond appropriately (e.g. suggest an alternative, ask clarifying questions). The `addToolOutput` function also works for tools in `approval-requested` or `approval-responded` states, not just `input-available`.

`addToolApprovalResponse` (with `approved: false`) auto-continues the conversation when `autoContinueAfterToolResult` is enabled (the default), so the LLM sees the denial and can respond naturally.

`addToolOutput` with `state: "output-error"` does **not** auto-continue — it gives you full control over what happens next. If you want the LLM to respond to the error, call `sendMessage()` afterward.

See the complete example: [guides/human-in-the-loop/](../guides/human-in-the-loop/)

## Client-Side Tool Execution with `onToolCall`
Expand Down
23 changes: 23 additions & 0 deletions examples/ai-chat/src/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,29 @@ function Chat() {
);
}

// Tool denied
if (part.state === "output-denied") {
return (
<div
key={part.toolCallId}
className="flex justify-start"
>
<Surface className="max-w-[85%] px-4 py-2.5 rounded-xl ring ring-kumo-line">
<div className="flex items-center gap-2">
<XCircleIcon
size={14}
className="text-kumo-inactive"
/>
<Text size="xs" variant="secondary" bold>
{toolName}
</Text>
<Badge variant="secondary">Denied</Badge>
</div>
</Surface>
</div>
);
}

// Tool executing
if (
part.state === "input-available" ||
Expand Down
14 changes: 14 additions & 0 deletions examples/playground/src/demos/ai/ToolsDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ function ToolCard({

const isApproval = state === "approval-requested";
const isDone = state === "output-available";
const isDenied = state === "output-denied";
const isError = state === "output-error";
const isRunning = state === "input-available" || state === "input-streaming";

return (
Expand Down Expand Up @@ -198,6 +200,18 @@ function ToolCard({
</Badge>
)}
{isApproval && <Badge variant="destructive">Needs Approval</Badge>}
{isDenied && (
<Badge variant="secondary">
<XCircleIcon size={10} className="mr-0.5" />
Denied
</Badge>
)}
{isError && (
<Badge variant="secondary">
<XCircleIcon size={10} className="mr-0.5" />
Error
</Badge>
)}
</div>

{toolInput != null && (
Expand Down
Loading
Loading