# How to Build an AI Agent That Asks Permission First (Nuxt + AI SDK 7)

> Source: <https://dev.to/aws/how-to-build-an-ai-agent-that-asks-permission-first-nuxt-ai-sdk-7-n42>
> Published: 2026-08-18 18:38:25+00:00

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!

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](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).

[Watch the full video on YouTube](https://www.youtube.com/watch?v=RktwtjobGI4).

You need:

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](https://github.com/ErikCH/blog-posts/tree/main/apps/ai-sdk-7-nuxt-agents).

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. The boundary belongs inside the tool:

``` js
// 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 containment check happens after `realpath()`

. That catches a path that looked local before resolution but points outside the fixture directory through a symbolic link.

Create the provider in `server/utils/bedrock.ts`

:

``` js
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 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.

Do 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:

```
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:

``` js
// 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`

matters. 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.

Add one option to the `streamText()`

call:

``` js
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 policy 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. The product decides which policy applies.

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.

Try 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.

[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()`

from `@ai-sdk/vue`

and the `isToolApprovalPending()`

helper from Nuxt UI:

``` js
<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>
```

Show the arguments. 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. One click changed the outcome.

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](https://kiro.dev/docs/cli/acp/?trk=1ad04439-1c50-4fdd-a845-d07d2655fe7a&sc_channel=el).

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:

``` js
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](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()`

against the parked 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.

Tool approval is a product control. It is not a security boundary.

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.

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.

The interesting part of tool approval is not the button. It is the pause between a model deciding to act and the tool changing something.

AI SDK 7 turns that pause into a supported message state. Nuxt can render it with `useChat()`

, `isToolApprovalPending()`

, and `addToolApprovalResponse()`

. The same UI can sit in front of a model call or a coding agent running through ACP.

The final rule stays boring and useful. Approval decides whether a tool runs. The tool still decides what it can reach. You need both controls.
