Introduction
I did something stupid. I built a superhero-themed Nuxt app, connected it to an Anthropic model through Amazon Bedrock, and gave it a tool that deletes files from my computer. In fact, if I wasn't careful, it could have deleted all my files!
The first time I tried it, I didn't use any sort of approval mechanism. And as you expected it just deleted things. Then I looked into how my coding agent works, and I learned about tool approvals.
I learned that AI SDK 7 has a tool approval at the model-call level. It works by pausing for an approval, showing an approval window, and then deleting it. I then put Kiro CLI behind the same interface using Agent Client Protocol (ACP).
Watch the full video on YouTube.
Prerequisites
You need:
- Node.js 22 or later. AI SDK 7 requires Node.js 22 and uses ECMAScript modules (ESM).
- npm 11 or another package manager that works with Nuxt 4.
- AWS credentials available through the standard provider chain.
- Access to an Amazon Bedrock model in your AWS Region.
- The AWS CLI if you want to list the inference profiles available to your account.
- An authenticated Kiro CLI installation for the optional ACP section.
Step 1: Create the Nuxt app
Create the project and install the versions used in the recorded demo:
npx nuxi@latest init nuxt-agent-approval
cd nuxt-agent-approval
npm install \
nuxt@4.5.2 \
vue@3.5.41 \
ai@7.0.66 \
@ai-sdk/vue@4.0.66 \
@ai-sdk/amazon-bedrock@5.0.57 \
@aws-sdk/credential-providers@3.1111.0 \
@nuxt/ui@4.10.0 \
zod@4.4.3
npm install -D @iconify-json/lucide@1.2.123
Register Nuxt UI and expose the Amazon Bedrock settings through server-side runtime config:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
css: ['~/assets/css/main.css'],
runtimeConfig: {
awsRegion: process.env.AWS_REGION ?? 'us-west-2',
bedrockModelId: process.env.NUXT_BEDROCK_MODEL_ID
}
})
Add the two Nuxt UI imports:
/* app/assets/css/main.css */
@import "tailwindcss";
@import "@nuxt/ui";
You can compare your setup with the complete companion project.
Step 2: Keep the file tool inside a fixture directory
The video uses two fixture files, old-draft.md and keep-me.md. Create them before adding the tools:
mkdir -p fixtures
printf '# Old draft\n' > fixtures/old-draft.md
printf '# Keep me\n' > fixtures/keep-me.md
The agent can list or remove files in that directory, but it should not accept a path such as ../../package.json.
Approval decides whether a tool runs. It does not decide what the tool can reach after it starts.
// server/utils/file-tools.ts
import { lstat, readdir, realpath, rm } from 'node:fs/promises'
import { resolve, relative, isAbsolute } from 'node:path'
import { tool } from 'ai'
import * as z from 'zod'
function resolveInsideFixtures(inputPath: string): string {
const root = resolve(process.cwd(), 'fixtures')
const target = resolve(root, inputPath)
const rel = relative(root, target)
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
throw createError({
statusCode: 400,
statusMessage: `Path escapes the fixtures directory: ${inputPath}`
})
}
return target
}
export const listFiles = tool({
description: 'List the files in the project fixtures directory.',
inputSchema: z.object({}),
execute: async () => {
const entries = await readdir(resolve(process.cwd(), 'fixtures'), {
withFileTypes: true
})
return { files: entries.filter(entry => entry.isFile()).map(entry => entry.name) }
}
})
export const deleteFile = tool({
description: 'Permanently delete one file from the fixtures directory.',
inputSchema: z.object({ path: z.string() }),
execute: async ({ path }) => {
const target = resolveInsideFixtures(path)
const info = await lstat(target).catch(() => null)
if (!info?.isFile() || info.isSymbolicLink()) {
return { deleted: false, path, reason: 'Not a regular file' }
}
const root = await realpath(resolve(process.cwd(), 'fixtures'))
const canonicalTarget = await realpath(target)
const rel = relative(root, canonicalTarget)
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
throw createError({
statusCode: 400,
statusMessage: 'File resolves outside fixtures'
})
}
await rm(canonicalTarget)
return { deleted: true, path }
}
})
The second check happens after realpath(). That catches a path that looked local before resolution but points outside the fixture directory through a symbolic link.
Step 3: Connect the route to Amazon Bedrock
I used Amazon Bedrock, but you can use provider with AI-SDK
Create the provider in server/utils/bedrock.ts:
import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'
import { fromNodeProviderChain } from '@aws-sdk/credential-providers'
export function useBedrock() {
const { awsRegion } = useRuntimeConfig()
return createAmazonBedrock({
region: awsRegion,
credentialProvider: fromNodeProviderChain()
})
}
fromNodeProviderChain() uses the AWS credentials already available to your local environment, including AWS IAM Identity Center sessions, named profiles, environment variables, and instance roles. You do not need to put a long-lived access key in the Nuxt project.
Do not copy a model ID from this post. Available IDs vary by account and AWS Region. List the active Amazon Bedrock inference profiles for your account:
aws bedrock list-inference-profiles \
--region us-west-2 \
--query 'inferenceProfileSummaries[?status==`ACTIVE`].inferenceProfileId'
Set one of the returned profile IDs before starting Nuxt:
export AWS_REGION=us-west-2
read -r -p "Inference profile ID: " NUXT_BEDROCK_MODEL_ID
export NUXT_BEDROCK_MODEL_ID
npm run dev
Now add an unguarded chat route. Starting without approval makes the failure visible:
// server/api/chat.post.ts
import {
streamText,
stepCountIs,
convertToModelMessages,
toUIMessageStream,
createUIMessageStreamResponse
} from 'ai'
import type { AmazonBedrockProvider } from '@ai-sdk/amazon-bedrock'
type BedrockModelId = Parameters<AmazonBedrockProvider>[0]
export default defineEventHandler(async event => {
const { messages } = await readBody(event)
const { bedrockModelId } = useRuntimeConfig()
const bedrock = useBedrock()
const result = streamText({
model: bedrock(bedrockModelId as BedrockModelId),
instructions:
'Manage files in the fixture project. List files before deleting. Never guess a filename.',
messages: await convertToModelMessages(messages),
tools: { listFiles, deleteFile },
stopWhen: stepCountIs(5)
})
const stream = toUIMessageStream({ stream: result.stream })
return createUIMessageStreamResponse({ stream })
})
stopWhen is important. AI SDK 7 stops after one step by default. The model can call deleteFile, receive the result, and then stop before it tells the user what happened. Five steps leave room to list, delete, and summarize while keeping the loop bounded.
At this point, delete old-draft.md removes the file as soon as the model selects the tool. That is what happened in the first minute of the video.
Step 4: Add approval to the delete tool
Add one option to the streamText() call (toolApproval):
const result = streamText({
model: bedrock(bedrockModelId as BedrockModelId),
instructions:
'Manage files in the fixture project. List files before deleting. Never guess a filename.',
messages: await convertToModelMessages(messages),
tools: { listFiles, deleteFile },
stopWhen: stepCountIs(5),
toolApproval: {
deleteFile: 'user-approval'
}
})
The option lives on streamText(), not inside the tool definition. The same deleteFile tool might run unattended in a maintenance job and require a person in a customer-facing chat.
AI SDK 7 supports more than a yes-or-no policy. A policy function can approve a call, deny it without asking, or send it to the user. This example uses the direct user-approval status because every delete should stop.
Step 5: Render the approval request in Nuxt
Nuxt UI's chat documentation follows the same AI SDK message-part model. The recorded app uses useChat() from @ai-sdk/vue and the isToolApprovalPending() helper from Nuxt UI:
<script setup lang="ts">
import {
DefaultChatTransport,
getToolName,
isTextUIPart,
isToolUIPart,
lastAssistantMessageIsCompleteWithApprovalResponses
} from 'ai'
import { useChat } from '@ai-sdk/vue'
import { isToolApprovalPending } from '@nuxt/ui/utils/ai'
const input = ref('')
const {
messages,
status,
sendMessage,
addToolApprovalResponse
} = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses
})
function onSubmit() {
if (!input.value.trim()) return
sendMessage({ text: input.value })
input.value = ''
}
</script>
sendAutomaticallyWhen resumes the interrupted turn after the user answers. Without it, the approval state changes in the browser but the agent does not continue on its own.
Render tool parts and attach the two decisions while approval is pending:
<template>
<div v-for="message in messages" :key="message.id">
<template
v-for="(part, index) in message.parts"
:key="`${message.id}-${part.type}-${index}`"
>
<div v-if="isToolUIPart(part)" class="tool-card">
<strong>{{ getToolName(part) }}</strong>
<pre v-if="part.input">{{ part.input }}</pre>
<div v-if="isToolApprovalPending(part)">
<button
type="button"
@click="addToolApprovalResponse({
id: part.approval!.id,
approved: true
})"
>
Do it
</button>
<button
type="button"
@click="addToolApprovalResponse({
id: part.approval!.id,
approved: false,
reason: 'The user declined this file deletion.'
})"
>
Nope
</button>
</div>
</div>
<p v-else-if="isTextUIPart(part)">{{ part.text }}</p>
</template>
</div>
<form @submit.prevent="onSubmit">
<input v-model="input" placeholder="delete old-draft.md">
<button :disabled="status !== 'ready'">Send</button>
</form>
</template>
FYI, An approval button is not useful when the person cannot see which path the agent wants to remove.
The denial reason is also useful. approved: false tells the model it cannot run the tool. The reason gives it enough context to explain what happened rather than trying the same call again.
Run the prompt twice. Deny it once and confirm that old-draft.md remains. Reset the fixtures, repeat the prompt, approve it, and confirm that the file disappears. The model received the same request both times.
Step 6: Put Kiro CLI behind the same Nuxt UI
The video switches the backend for the final demo. Instead of sending the prompt directly to an Anthropic model through Amazon Bedrock, the Nuxt app talks to Kiro CLI over Agent Client Protocol.
ACP gives the app a common way to start an agent session, send a prompt, receive tool events, and answer permission requests. Kiro runs as a separate process and keeps its existing agent tools and Model Context Protocol (MCP) integrations. The Nuxt app remains responsible for the interface and the host tools it exposes.
Install the AI SDK harness packages:
npm install \
@ai-sdk/harness@1.0.73 \
@ai-sdk/harness-acp@1.0.11
The Kiro route creates a HarnessAgent instead of calling streamText() directly. The createKiroHarness() ACP preset and createUnsafeLocalSandbox() development adapter come from the companion project, so treat this as the route configuration rather than a standalone file:
import { execFileSync } from 'node:child_process'
import { HarnessAgent } from '@ai-sdk/harness/agent'
const kiroExecutable = execFileSync('which', ['kiro-cli'], {
encoding: 'utf-8'
}).trim()
const agent = new HarnessAgent({
harness: createKiroHarness({ port: 4100 }),
sandbox: createUnsafeLocalSandbox({
ports: [4100],
hostBins: [
{ harnessId: 'kiro', name: 'kiro-cli', target: kiroExecutable }
]
}),
permissionMode: 'allow-reads',
instructions:
'Manage files in the fixture project. List files before deleting. Never guess a filename.',
tools: { listFiles, deleteFile },
toolApproval: {
deleteFile: 'user-approval'
}
})
The complete Kiro route and ACP preset include session creation and approval continuation. When a response comes back from the browser, the route gathers pending approval responses and calls continueStream() against the Kiro session.
My recorded run asked more than once before deleting keep-me.md. Kiro confirmed the target, its permission flow asked to run the tool, and the host deleteFile policy asked for the final approval. It was a little repetitive, but it exposed an important boundary. Kiro's built-in permissions and AI SDK's host-tool approval are separate systems.
The local sandbox in this sample is a development adapter. It limits file API paths to a temporary root, but processes still run as the current operating-system user. Replace it with an isolated sandbox provider before exposing a coding agent to untrusted prompts.
Step 7: Keep approval in its lane
Tool approval is a product DX improvement, it isn't security.
If a person approves the wrong path, the tool still removes the wrong path. If the tool can reach the rest of the filesystem, approval does not narrow that access. Keep the path checks from Step 2, apply authorization inside the tool, validate inputs on the server, and use an isolated runtime for agents that can run commands.
The sample app also keeps its recording controls in development mode. The browser can turn approval off for the first demo, but the built app ignores that flag and requires approval. A client-controlled switch that disables confirmation should not ship.
Cleanup
Stop the Nuxt development server. If you deleted either fixture during the demo, recreate both files before your next run:
printf '# Old draft\n' > fixtures/old-draft.md
printf '# Keep me\n' > fixtures/keep-me.md
This tutorial does not provision AWS resources. Amazon Bedrock requests can still incur charges, so stop sending test prompts when you finish.
If you delete the local project directory, its files and any local session state are removed. Copy anything you want to keep before deleting it. AWS credentials loaded through the provider chain remain in their original profile or identity-center cache; this app does not write them into the project.
Finale
I learned a lot by adding this tool approval process. And even though I normally use Strands Agents, the AI SDK 7 worked really well.
Make sure to leave a comment below if you got this far!

Top comments (9)
Really like the permission-first approach here. As AI agents become more capable of taking actions rather than just generating responses, giving users a clear point of control feels increasingly important. The distinction between “the agent can do this” and “the agent is allowed to do this” is easy to overlook, but it makes a big difference for trust and safety.
Yup
Solid writeup, and "tool approval isn't security" is the right framing. One failure mode worth adding, because your deleteFile example has it : the approval gap is a TOCTOU window. The human approves at T0 against a rendered path, execution runs at T1, and nothing pins the resource in between. Your double realpath check validates twice, but both checks happen before the human sits on the approve button for ten seconds while the filesystem moves underneath.
The fix that has worked for us in production (approvals gating onchain signatures, where a stale approval is unrecoverable) : resolve and freeze the operation before prompting, bind the approval to a digest of that frozen state, and re-verify at execution. For files that means capturing realpath plus stat dev/ino before the prompt, then re-checking dev/ino right before the unlink. For anything else, hash the fully resolved tool input and reject on mismatch. The approval then covers bytes, not a rendering.
Related hardening that costs one field: make approvals single-use and bound to the specific call id. addToolApprovalResponse replayed against a second pending call with the same shape is a real path once the UI is compromised or just buggy, and server-side pairing of approval-to-call-digest kills it.
Agreed on the underrated part : the denial reason flowing back to the model is the feature people will sleep on. A bare rejection teaches the agent nothing, a reason redirects the next attempt.
I’ve run into a similar concern while building AI-powered document tools. Giving an agent the ability to process files is easy; deciding which actions should require human approval is much harder. The “approval isn’t security” distinction is especially important.
finally someone showing how to actually handle agent permission, ngl it's way too easy for these things to just go rogue lol
Yes for sure!
Have you tried out ACP?
Permission checkpoints work best when they are tied to reversibility and blast radius, not added to every tool call. Reading a fixture or running a local test can stay automatic; deleting files, changing production state, or sending something externally deserves an explicit approval with a clear preview of the action. For coding agents, I’d also make the approval carry enough context to be meaningful—what will change, how many files/resources are affected, and what rollback looks like. That preserves autonomy for the boring path while keeping humans focused on consequential transitions.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.