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 (1)
Have you tried out ACP?