{"slug": "how-to-build-an-ai-agent-that-asks-permission-first-nuxt-ai-sdk-7", "title": "How to Build an AI Agent That Asks Permission First (Nuxt + AI SDK 7)", "summary": "A developer built a Nuxt app with an AI agent powered by Anthropic's Claude via Amazon Bedrock, and integrated tool approval from AI SDK 7 to require user permission before executing file-deletion tools. The implementation includes a path-boundary check to prevent the agent from accessing files outside a designated fixtures directory. The project is available as a companion repository on GitHub.", "body_md": "I did something stupid. I built a superhero-themed Nuxt app, connected it to an Anthropic model through [Amazon Bedrock](https://aws.amazon.com/bedrock/?trk=1ad04439-1c50-4fdd-a845-d07d2655fe7a&sc_channel=el), and gave it a tool that deletes files from my computer. In fact, if I wasn't careful, it could delete all my files!\n\nThe 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.\n\nI learned that [AI SDK 7](https://vercel.com/blog/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](https://kiro.dev/docs/cli/?trk=1ad04439-1c50-4fdd-a845-d07d2655fe7a&sc_channel=el) behind the same interface using Agent Client Protocol (ACP).\n\n[Watch the full video on YouTube](https://www.youtube.com/watch?v=RktwtjobGI4).\n\nYou need:\n\nCreate the project and install the versions used in the recorded demo:\n\n```\nnpx nuxi@latest init nuxt-agent-approval\ncd nuxt-agent-approval\n\nnpm install \\\n  nuxt@4.5.2 \\\n  vue@3.5.41 \\\n  ai@7.0.66 \\\n  @ai-sdk/vue@4.0.66 \\\n  @ai-sdk/amazon-bedrock@5.0.57 \\\n  @aws-sdk/credential-providers@3.1111.0 \\\n  @nuxt/ui@4.10.0 \\\n  zod@4.4.3\n\nnpm install -D @iconify-json/lucide@1.2.123\n```\n\nRegister Nuxt UI and expose the Amazon Bedrock settings through server-side runtime config:\n\n```\n// nuxt.config.ts\nexport default defineNuxtConfig({\n  modules: ['@nuxt/ui'],\n  css: ['~/assets/css/main.css'],\n  runtimeConfig: {\n    awsRegion: process.env.AWS_REGION ?? 'us-west-2',\n    bedrockModelId: process.env.NUXT_BEDROCK_MODEL_ID\n  }\n})\n```\n\nAdd the two Nuxt UI imports:\n\n```\n/* app/assets/css/main.css */\n@import \"tailwindcss\";\n@import \"@nuxt/ui\";\n```\n\nYou can compare your setup with the [complete companion project](https://github.com/ErikCH/blog-posts/tree/main/apps/ai-sdk-7-nuxt-agents).\n\nThe video uses two fixture files, `old-draft.md`\n\nand `keep-me.md`\n\n. Create them before adding the tools:\n\n```\nmkdir -p fixtures\nprintf '# Old draft\\n' > fixtures/old-draft.md\nprintf '# Keep me\\n' > fixtures/keep-me.md\n```\n\nThe agent can list or remove files in that directory, but it should not accept a path such as `../../package.json`\n\n.\n\nApproval decides whether a tool runs. It does not decide what the tool can reach after it starts. The boundary belongs inside the tool:\n\n``` js\n// server/utils/file-tools.ts\nimport { lstat, readdir, realpath, rm } from 'node:fs/promises'\nimport { resolve, relative, isAbsolute } from 'node:path'\nimport { tool } from 'ai'\nimport * as z from 'zod'\n\nfunction resolveInsideFixtures(inputPath: string): string {\n  const root = resolve(process.cwd(), 'fixtures')\n  const target = resolve(root, inputPath)\n  const rel = relative(root, target)\n\n  if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {\n    throw createError({\n      statusCode: 400,\n      statusMessage: `Path escapes the fixtures directory: ${inputPath}`\n    })\n  }\n\n  return target\n}\n\nexport const listFiles = tool({\n  description: 'List the files in the project fixtures directory.',\n  inputSchema: z.object({}),\n  execute: async () => {\n    const entries = await readdir(resolve(process.cwd(), 'fixtures'), {\n      withFileTypes: true\n    })\n    return { files: entries.filter(entry => entry.isFile()).map(entry => entry.name) }\n  }\n})\n\nexport const deleteFile = tool({\n  description: 'Permanently delete one file from the fixtures directory.',\n  inputSchema: z.object({ path: z.string() }),\n  execute: async ({ path }) => {\n    const target = resolveInsideFixtures(path)\n    const info = await lstat(target).catch(() => null)\n\n    if (!info?.isFile() || info.isSymbolicLink()) {\n      return { deleted: false, path, reason: 'Not a regular file' }\n    }\n\n    const root = await realpath(resolve(process.cwd(), 'fixtures'))\n    const canonicalTarget = await realpath(target)\n    const rel = relative(root, canonicalTarget)\n\n    if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {\n      throw createError({\n        statusCode: 400,\n        statusMessage: 'File resolves outside fixtures'\n      })\n    }\n\n    await rm(canonicalTarget)\n    return { deleted: true, path }\n  }\n})\n```\n\nThe second containment check happens after `realpath()`\n\n. That catches a path that looked local before resolution but points outside the fixture directory through a symbolic link.\n\nCreate the provider in `server/utils/bedrock.ts`\n\n:\n\n``` js\nimport { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'\nimport { fromNodeProviderChain } from '@aws-sdk/credential-providers'\n\nexport function useBedrock() {\n  const { awsRegion } = useRuntimeConfig()\n\n  return createAmazonBedrock({\n    region: awsRegion,\n    credentialProvider: fromNodeProviderChain()\n  })\n}\n```\n\n`fromNodeProviderChain()`\n\nuses the AWS credentials already available to your process, including [AWS IAM Identity Center](https://aws.amazon.com/iam/identity-center/?trk=1ad04439-1c50-4fdd-a845-d07d2655fe7a&sc_channel=el) sessions, named profiles, environment variables, and instance roles. You do not need to put a long-lived access key in the Nuxt project.\n\nDo not copy a model ID from this post. Available IDs vary by account and AWS Region. List the active [Amazon Bedrock inference profiles](https://aws.amazon.com/blogs/machine-learning/getting-started-with-cross-region-inference-in-amazon-bedrock/?trk=1ad04439-1c50-4fdd-a845-d07d2655fe7a&sc_channel=el) for your account:\n\n```\naws bedrock list-inference-profiles \\\n  --region us-west-2 \\\n  --query 'inferenceProfileSummaries[?status==`ACTIVE`].inferenceProfileId'\n```\n\nSet one of the returned profile IDs before starting Nuxt:\n\n```\nexport AWS_REGION=us-west-2\nread -r -p \"Inference profile ID: \" NUXT_BEDROCK_MODEL_ID\nexport NUXT_BEDROCK_MODEL_ID\nnpm run dev\n```\n\nNow add an unguarded chat route. Starting without approval makes the failure visible:\n\n``` js\n// server/api/chat.post.ts\nimport {\n  streamText,\n  stepCountIs,\n  convertToModelMessages,\n  toUIMessageStream,\n  createUIMessageStreamResponse\n} from 'ai'\nimport type { AmazonBedrockProvider } from '@ai-sdk/amazon-bedrock'\n\ntype BedrockModelId = Parameters<AmazonBedrockProvider>[0]\n\nexport default defineEventHandler(async event => {\n  const { messages } = await readBody(event)\n  const { bedrockModelId } = useRuntimeConfig()\n  const bedrock = useBedrock()\n\n  const result = streamText({\n    model: bedrock(bedrockModelId as BedrockModelId),\n    instructions:\n      'Manage files in the fixture project. List files before deleting. Never guess a filename.',\n    messages: await convertToModelMessages(messages),\n    tools: { listFiles, deleteFile },\n    stopWhen: stepCountIs(5)\n  })\n\n  const stream = toUIMessageStream({ stream: result.stream })\n  return createUIMessageStreamResponse({ stream })\n})\n```\n\n`stopWhen`\n\nmatters. AI SDK 7 stops after one step by default. The model can call `deleteFile`\n\n, 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.\n\nAt this point, `delete old-draft.md`\n\nremoves the file as soon as the model selects the tool. That is what happened in the first minute of the video.\n\nAdd one option to the `streamText()`\n\ncall:\n\n``` js\nconst result = streamText({\n  model: bedrock(bedrockModelId as BedrockModelId),\n  instructions:\n    'Manage files in the fixture project. List files before deleting. Never guess a filename.',\n  messages: await convertToModelMessages(messages),\n  tools: { listFiles, deleteFile },\n  stopWhen: stepCountIs(5),\n  toolApproval: {\n    deleteFile: 'user-approval'\n  }\n})\n```\n\nThe policy lives on `streamText()`\n\n, not inside the tool definition. The same `deleteFile`\n\ntool might run unattended in a maintenance job and require a person in a customer-facing chat. The product decides which policy applies.\n\nAI 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`\n\nstatus because every delete should stop.\n\nTry the prompt again. The file stays in place, but the page appears frozen. The run is waiting for an answer that the UI has not rendered yet.\n\n[Nuxt UI's chat documentation](https://ui.nuxt.com/docs/components/chat) follows the same AI SDK message-part model. The recorded app uses `useChat()`\n\nfrom `@ai-sdk/vue`\n\nand the `isToolApprovalPending()`\n\nhelper from Nuxt UI:\n\n``` js\n<script setup lang=\"ts\">\nimport {\n  DefaultChatTransport,\n  getToolName,\n  isTextUIPart,\n  isToolUIPart,\n  lastAssistantMessageIsCompleteWithApprovalResponses\n} from 'ai'\nimport { useChat } from '@ai-sdk/vue'\nimport { isToolApprovalPending } from '@nuxt/ui/utils/ai'\n\nconst input = ref('')\n\nconst {\n  messages,\n  status,\n  sendMessage,\n  addToolApprovalResponse\n} = useChat({\n  transport: new DefaultChatTransport({ api: '/api/chat' }),\n  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses\n})\n\nfunction onSubmit() {\n  if (!input.value.trim()) return\n  sendMessage({ text: input.value })\n  input.value = ''\n}\n</script>\n```\n\n`sendAutomaticallyWhen`\n\nresumes 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.\n\nRender tool parts and attach the two decisions while approval is pending:\n\n```\n<template>\n  <div v-for=\"message in messages\" :key=\"message.id\">\n    <template\n      v-for=\"(part, index) in message.parts\"\n      :key=\"`${message.id}-${part.type}-${index}`\"\n    >\n      <div v-if=\"isToolUIPart(part)\" class=\"tool-card\">\n        <strong>{{ getToolName(part) }}</strong>\n\n        <pre v-if=\"part.input\">{{ part.input }}</pre>\n\n        <div v-if=\"isToolApprovalPending(part)\">\n          <button\n            type=\"button\"\n            @click=\"addToolApprovalResponse({\n              id: part.approval!.id,\n              approved: true\n            })\"\n          >\n            Do it\n          </button>\n\n          <button\n            type=\"button\"\n            @click=\"addToolApprovalResponse({\n              id: part.approval!.id,\n              approved: false,\n              reason: 'The user declined this file deletion.'\n            })\"\n          >\n            Nope\n          </button>\n        </div>\n      </div>\n\n      <p v-else-if=\"isTextUIPart(part)\">{{ part.text }}</p>\n    </template>\n  </div>\n\n  <form @submit.prevent=\"onSubmit\">\n    <input v-model=\"input\" placeholder=\"delete old-draft.md\">\n    <button :disabled=\"status !== 'ready'\">Send</button>\n  </form>\n</template>\n```\n\nShow the arguments. An approval button is not useful when the person cannot see which path the agent wants to remove.\n\nThe denial reason is also useful. `approved: false`\n\ntells the model it cannot run the tool. The reason gives it enough context to explain what happened rather than trying the same call again.\n\nRun the prompt twice. Deny it once and confirm that `old-draft.md`\n\nremains. Reset the fixtures, repeat the prompt, approve it, and confirm that the file disappears. The model received the same request both times. One click changed the outcome.\n\nThe 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](https://kiro.dev/docs/cli/acp/?trk=1ad04439-1c50-4fdd-a845-d07d2655fe7a&sc_channel=el).\n\nACP 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.\n\nInstall the AI SDK harness packages:\n\n```\nnpm install \\\n  @ai-sdk/harness@1.0.73 \\\n  @ai-sdk/harness-acp@1.0.11\n```\n\nThe Kiro route creates a `HarnessAgent`\n\ninstead of calling `streamText()`\n\ndirectly. The `createKiroHarness()`\n\nACP preset and `createUnsafeLocalSandbox()`\n\ndevelopment adapter come from the companion project, so treat this as the route configuration rather than a standalone file:\n\n``` js\nimport { execFileSync } from 'node:child_process'\nimport { HarnessAgent } from '@ai-sdk/harness/agent'\n\nconst kiroExecutable = execFileSync('which', ['kiro-cli'], {\n  encoding: 'utf-8'\n}).trim()\n\nconst agent = new HarnessAgent({\n  harness: createKiroHarness({ port: 4100 }),\n  sandbox: createUnsafeLocalSandbox({\n    ports: [4100],\n    hostBins: [\n      { harnessId: 'kiro', name: 'kiro-cli', target: kiroExecutable }\n    ]\n  }),\n  permissionMode: 'allow-reads',\n  instructions:\n    'Manage files in the fixture project. List files before deleting. Never guess a filename.',\n  tools: { listFiles, deleteFile },\n  toolApproval: {\n    deleteFile: 'user-approval'\n  }\n})\n```\n\nThe [complete Kiro route and ACP preset](https://github.com/ErikCH/blog-posts/tree/main/apps/ai-sdk-7-nuxt-agents/server) include session creation and approval continuation. When a response comes back from the browser, the route gathers pending approval responses and calls `continueStream()`\n\nagainst the parked Kiro session.\n\nMy recorded run asked more than once before deleting `keep-me.md`\n\n. Kiro confirmed the target, its permission flow asked to run the tool, and the host `deleteFile`\n\npolicy 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.\n\nThe 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.\n\nTool approval is a product control. It is not a security boundary.\n\nIf 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.\n\nThe 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.\n\nStop the Nuxt development server. If you deleted either fixture during the demo, recreate both files before your next run:\n\n```\nprintf '# Old draft\\n' > fixtures/old-draft.md\nprintf '# Keep me\\n' > fixtures/keep-me.md\n```\n\nThis tutorial does not provision AWS resources. Amazon Bedrock requests can still incur charges, so stop sending test prompts when you finish.\n\nIf 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.\n\nThe interesting part of tool approval is not the button. It is the pause between a model deciding to act and the tool changing something.\n\nAI SDK 7 turns that pause into a supported message state. Nuxt can render it with `useChat()`\n\n, `isToolApprovalPending()`\n\n, and `addToolApprovalResponse()`\n\n. The same UI can sit in front of a model call or a coding agent running through ACP.\n\nThe final rule stays boring and useful. Approval decides whether a tool runs. The tool still decides what it can reach. You need both controls.", "url": "https://wpnews.pro/news/how-to-build-an-ai-agent-that-asks-permission-first-nuxt-ai-sdk-7", "canonical_source": "https://dev.to/aws/how-to-build-an-ai-agent-that-asks-permission-first-nuxt-ai-sdk-7-n42", "published_at": "2026-08-18 18:38:25+00:00", "updated_at": "2026-08-18 18:44:13.412984+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-agents", "developer-tools", "ai-safety", "ai-products"], "entities": ["Nuxt", "AI SDK 7", "Anthropic", "Amazon Bedrock", "Kiro CLI", "Agent Client Protocol", "ErikCH", "Vercel"], "alternates": {"html": "https://wpnews.pro/news/how-to-build-an-ai-agent-that-asks-permission-first-nuxt-ai-sdk-7", "markdown": "https://wpnews.pro/news/how-to-build-an-ai-agent-that-asks-permission-first-nuxt-ai-sdk-7.md", "text": "https://wpnews.pro/news/how-to-build-an-ai-agent-that-asks-permission-first-nuxt-ai-sdk-7.txt", "jsonld": "https://wpnews.pro/news/how-to-build-an-ai-agent-that-asks-permission-first-nuxt-ai-sdk-7.jsonld"}}