---
title: Build a Shopify agent with AI SDK and Vercel Connect
description: Build a Shopify agent with AI SDK and Vercel Connect to search products, check orders, and request approval before changing your catalog.
url: "https://vercel.com/kb/guide/shopify-agent-ai-sdk-vercel-connect"
published: 2026-11-10
last_updated: 2026-11-11
authors: Ben Sabic
related_resources:
  - title: "Build a Shopify operations agent with Vercel Connect"
    url: "/kb/prompts/build-shopify-operations-agent"
    description: "Create a staff agent that searches products and checks orders through Vercel Connect, and asks for approval before it changes your catalog."
  - title: "Deploy a Shopify storefront with an AI assistant"
    url: "/kb/guide/deploy-shopify-ai-storefront"
    description: "Deploy an eve shopping agent with Vercel Shop to find Shopify products, manage carts, and answer store questions."
install_vercel_plugin: npx plugins add vercel/vercel-plugin
---

Staff can ask a store operations agent which products are running low, check an order's status, or propose a catalog edit. The agent retrieves current Shopify data and asks for approval before submitting a product change.

[Vercel Connect](https://vercel.com/connect) manages the Shopify connection and supplies short-lived Admin API tokens to your server. The [AI SDK](https://ai-sdk.dev/) handles the agent's tools and chat responses, with model requests routed through [AI Gateway](https://vercel.com/ai-gateway). Both Connect and AI Gateway can authenticate the deployment through Vercel OIDC.

## Overview

Start with the staff agent for interactive store operations. The optional extensions add catalog analysis, background reviews, or a separate shopper-facing assistant.

| Build                     | Start with                                       | Additional setup                               |
| ------------------------- | ------------------------------------------------ | ---------------------------------------------- |
| Staff chat agent          | Steps 1–11                                       | Staff sign-in and permission checks            |
| Catalog analysis          | Staff agent, then the Drives add-on              | Persistent workspace and sandbox execution     |
| Background order reviews  | Shopify connector setup, then the Daemons add-on | Worker deployment and order-write access       |
| Shopper account assistant | Customer Accounts MCP add-on                     | Shopper sign-in and a separate OAuth connector |

## Prerequisites

Before you begin, prepare:

- A [Vercel account](https://vercel.com/signup).
  
- A Shopify development store for app installation and product edits
  
- Server-side sign-in and staff permission checks for the connected store
  
- A private signing secret for tool approvals, configured in step seven
  

For local development, you also need Node.js 24+ and the [Vercel CLI](https://vercel.com/docs/cli).

The staff session adapter must return a trusted user ID and enforce your store permissions before the agent route is deployed.

## How it works

The agent runs in a Next.js route handler. Each Shopify tool uses a Connect token to query the Admin API and return data to the model.

| Piece                      | Role                                                                                                |
| -------------------------- | --------------------------------------------------------------------------------------------------- |
| `ToolLoopAgent` (AI SDK 7) | Runs the model and tool loop, and pauses for approval before write tools run                        |
| AI Gateway                 | Routes model calls from a `creator/model-name` string, authenticated with your project's OIDC token |
| Vercel Connect             | Exchanges the same OIDC token for a short-lived Shopify Admin API token                             |
| Shopify Admin GraphQL API  | Serves product and order data at API version `2026-10`                                              |

The app doesn't need to persist Shopify credentials. Connect verifies the deployment's OIDC identity and its connector link for the project and environment.

The SDK keeps the token in server memory and reuses it for matching requests until it is within 30 seconds of expiry. Separate instances and concurrent cache misses can make additional token requests.

### Why not use a Shopify MCP server?

Shopify runs several MCP servers, but none of them gives a staff agent read and write access to a store's products and orders:

| Server                                                                                            | Access it provides                                           | Fit for this agent                                                      |
| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------- |
| [Shopify Dev MCP](https://shopify.dev/docs/apps/build/ai-toolkit#install-with-the-dev-mcp-server) | Shopify documentation, schemas, and development validation   | Helps build the integration; it does not access store records           |
| [Storefront Catalog MCP](https://shopify.dev/docs/agents/catalog/storefront-catalog)              | Public product discovery for shoppers                        | Useful for catalog browsing; it does not provide staff Admin API access |
| [Customer Accounts MCP](https://shopify.dev/docs/apps/build/customer-accounts-mcp)                | The authorized customer's orders and account                 | Use for the shopper-facing add-on                                       |
| A custom Admin API MCP server                                                                     | The operations and authentication its implementation exposes | Review its tools, credentials, and approval controls before use         |

Defining the tools yourself lets you expose the Admin API operations this agent needs and require approval for catalog changes. Each tool declares its requested scopes alongside the operation it calls.

The Customer Accounts server is a good fit for a different agent, one that shoppers talk to. The [last add-on](#add-on-let-shoppers-ask-about-their-own-orders-with-the-customer-accounts-mcp-server) builds that agent with Vercel Connect.

## Steps

### 1\. Create a Next.js app

**pnpm**

```bash
pnpm create next-app@latest shopify-agent --yes
cd shopify-agent
```

**yarn**

```bash
yarn create next-app shopify-agent --yes
cd shopify-agent
```

**npm**

```bash
npx create-next-app@latest shopify-agent --yes
cd shopify-agent
```

**bun**

```bash
bun create next-app@latest shopify-agent --yes
cd shopify-agent
```

### 2\. Install the packages

**pnpm**

```bash
pnpm add ai@^7 @ai-sdk/react@^4 @vercel/connect@^2.9 zod@^4
```

**yarn**

```bash
yarn add ai@^7 @ai-sdk/react@^4 @vercel/connect@^2.9 zod@^4
```

**npm**

```bash
npm install ai@^7 @ai-sdk/react@^4 @vercel/connect@^2.9 zod@^4
```

**bun**

```bash
bun add ai@^7 @ai-sdk/react@^4 @vercel/connect@^2.9 zod@^4
```

The examples use AI SDK 7, React adapter 4, and Connect 2.9 or later within major version 2. Keep the lockfile to deploy the versions you tested.

### 3\. Link the project and create the Shopify connector

Link the project, then create its Shopify connector:

```bash
vercel link
vercel connect create shopify --name acme-shopify
```

Use an app-level connection method with credentials for a Shopify app installed on the store. Create the app in Shopify before configuring the connector.

If you use the client-credentials grant, the app and store must belong to the same Shopify organization. To inspect the preset's connection methods, run:

```bash
vercel connect create shopify --help
```

Grant the app these Admin API access scopes:

| Scope            | Used by                                                               |
| ---------------- | --------------------------------------------------------------------- |
| `read_products`  | `searchProducts` and the Drives `exportCatalog` tool                  |
| `write_products` | `updateProduct`                                                       |
| `read_orders`    | `getOrder` and the order review daemon                                |
| `write_orders`   | Optional order review daemon; omit this scope for the chat-only setup |

Order access also follows Shopify's [protected customer data requirements](https://shopify.dev/docs/apps/launch/protected-customer-data). Public apps must request the data they use; custom apps have different access rules.

For development stores, select the required data and fields in the app dashboard. Production review is not required for development-only access. The daemon's shipping-address query needs address access.

Without the `read_all_orders` scope, which Shopify grants only after you request it, Shopify returns only the last 60 days of orders.

Attach the connector to your project. With no `--environment` option, the CLI links Production, Preview, and Development:

```bash
vercel connect attach shopify/acme-shopify
```

### 4\. Add your store domain and pull environment variables

Add your store's `myshopify.com` domain so the client knows where to send requests. The domain identifies the store and is safe to keep in configuration.

```bash
vercel env add SHOPIFY_STORE_DOMAIN
vercel env pull
```

Enter a value such as `acme.myshopify.com` and select the environments linked to this store. Pulling the environment variables creates `.env.local` with the development OIDC token used by Connect and AI Gateway.

Local OIDC tokens expire after about 12 hours. If authentication starts failing, run `vercel env pull` again.

If you named the connector something other than `acme-shopify`, also add `SHOPIFY_CONNECTOR` with the connector's UID, such as `shopify/my-store`.

### 5\. Create a Shopify client

Create `lib/shopify.ts` to pass each operation's requested scopes to Connect and send the GraphQL request:

```typescript
import { deleteTokenCacheEntry, getToken } from '@vercel/connect';

const CONNECTOR =
  process.env.SHOPIFY_CONNECTOR ?? 'shopify/acme-shopify';
// For example: acme.myshopify.com
const STORE_DOMAIN = process.env.SHOPIFY_STORE_DOMAIN;
const API_VERSION = '2026-10';

export const READ_PRODUCT_SCOPES = ['read_products'];
export const READ_ORDER_SCOPES = ['read_orders'];
export const WRITE_PRODUCT_SCOPES = ['write_products'];

type GraphQLResponse<T> = {
  data?: T;
  errors?: Array<{ message: string }>;
};

export async function shopifyGraphQL<T>(
  query: string,
  variables: Record<string, unknown>,
  scopes: string[],
): Promise<T> {
  if (!STORE_DOMAIN) {
    throw new Error(
      'Set SHOPIFY_STORE_DOMAIN, for example acme.myshopify.com',
    );
  }

  const params = { subject: { type: 'app' as const }, scopes };

  const send = async () => {
    const token = await getToken(CONNECTOR, params);
    return fetch(
      `https://${STORE_DOMAIN}/admin/api/${API_VERSION}/graphql.json`,
      {
        method: 'POST',
        headers: {
          'X-Shopify-Access-Token': token,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ query, variables }),
      },
    );
  };

  let response = await send();

  // Drop the rejected cached token and request a token again.
  if (response.status === 401) {
    deleteTokenCacheEntry(CONNECTOR, params);
    response = await send();
  }

  if (!response.ok) {
    const detail = await response.text();
    throw new Error(`Shopify returned ${response.status}: ${detail}`);
  }

  const body = (await response.json()) as GraphQLResponse<T>;
  if (body.errors?.length) {
    const detail = body.errors
      .map((error) => error.message)
      .join('; ');
    throw new Error(detail);
  }

  return body.data as T;
}
```

The `app` subject acts with your Shopify app's authority. It does not inherit the signed-in staff member's permissions.

Requested scopes describe what a tool needs, but the connector's authorization method determines the token's permissions. Shopify's [client-credentials grant](https://shopify.dev/docs/apps/build/authentication-authorization/client-credentials-grant) returns the installation's granted scopes even when fewer are requested. Approval controls whether the tool executes.

If Shopify rejects a token with HTTP 401, the helper removes its matching SDK cache entry and requests a token again. It retries the Shopify request once; Connect and Shopify determine whether the returned token is renewed.

### 6\. Define the agent's tools

Create `lib/tools.ts` with three tools. Each tool has a description the model reads, a Zod input schema, and an `execute` function that calls Shopify:

```typescript
import { tool } from 'ai';
import { z } from 'zod';
import {
  READ_PRODUCT_SCOPES,
  READ_ORDER_SCOPES,
  WRITE_PRODUCT_SCOPES,
  shopifyGraphQL,
} from './shopify';

const SEARCH_PRODUCTS = /* GraphQL */ `
  query SearchProducts($first: Int!, $query: String) {
    shop { currencyCode }
    products(first: $first, query: $query) {
      nodes {
        id
        title
        handle
        tags
        status
        totalInventory
        variants(first: 10) {
          nodes {
            id
            sku
            price
            inventoryQuantity
          }
        }
      }
    }
  }
`;

const FIND_ORDER = /* GraphQL */ `
  query FindOrder($query: String!) {
    orders(first: 1, query: $query) {
      nodes {
        id
        name
        createdAt
        displayFinancialStatus
        displayFulfillmentStatus
        totalPriceSet {
          shopMoney {
            amount
            currencyCode
          }
        }
        lineItems(first: 50) {
          nodes {
            title
            sku
            quantity
          }
        }
      }
    }
  }
`;

const UPDATE_PRODUCT = /* GraphQL */ `
  mutation UpdateProduct($product: ProductUpdateInput!) {
    productUpdate(product: $product) {
      product {
        id
        title
        tags
      }
      userErrors {
        field
        message
      }
    }
  }
`;

export const shopifyTools = {
  searchProducts: tool({
    description:
      'Search the store catalog using Shopify search syntax, ' +
      'such as "title:mug" or "status:active inventory_total:<5".',
    inputSchema: z.object({
      query: z.string().describe('Shopify product search query'),
      first: z.number().int().min(1).max(50).default(10),
    }),
    execute: async ({ query, first }) => {
      const data = await shopifyGraphQL<{
        shop: { currencyCode: string };
        products: { nodes: unknown[] };
      }>(
        SEARCH_PRODUCTS,
        { query, first },
        READ_PRODUCT_SCOPES,
      );
      return {
        currencyCode: data.shop.currencyCode,
        products: data.products.nodes,
      };
    },
  }),

  getOrder: tool({
    description: 'Look up one order by its name, such as #1001.',
    inputSchema: z.object({
      orderName: z.string().describe('Order name, such as #1001'),
    }),
    execute: async ({ orderName }) => {
      const name = orderName.replace(/^#/, '');
      const data = await shopifyGraphQL<{
        orders: { nodes: unknown[] };
      }>(
        FIND_ORDER,
        { query: `name:${JSON.stringify(name)}` },
        READ_ORDER_SCOPES,
      );
      return data.orders.nodes[0] ?? {
        error: `No order named ${orderName}`,
      };
    },
  }),

  updateProduct: tool({
    description:
      'Update a product title or description, or replace its tags. ' +
      'Look up the product first. When changing tags, include ' +
      'the existing tags you want to keep.',
    inputSchema: z.object({
      id: z.string().describe(
        'Product GID, such as gid://shopify/Product/123',
      ),
      title: z.string().optional(),
      descriptionHtml: z.string().optional(),
      tags: z.array(z.string())
        .describe(
          'The complete replacement tag list. ' +
          'Existing tags omitted from this list are removed.',
        )
        .optional(),
    }),
    execute: async (product) => {
      const data = await shopifyGraphQL<{
        productUpdate: {
          product: {
            id: string;
            title: string;
            tags: string[];
          } | null;
          userErrors: Array<{
            field: string[] | null;
            message: string;
          }>;
        };
      }>(UPDATE_PRODUCT, { product }, WRITE_PRODUCT_SCOPES);
      return data.productUpdate;
    },
  }),
};
```

Product search accepts Shopify filters such as `status`, `vendor`, `tag`, and `inventory_total`. Its result includes the shop currency so the agent can quote prices correctly. Order lookup accepts names such as `#1001`.

Product updates return Shopify's `userErrors` for the agent to explain. Passing `tags` replaces the complete tag list, so the lookup includes existing tags for the proposed replacement to retain.

These lookups return up to 10 variants per product and 50 line items per order. Add pagination before using them for analysis that requires every variant or line item.

### 7\. Create the agent

Create a private `TOOL_APPROVAL_SECRET` so the SDK can verify that approval requests came from your server. Generate a value, add it to each deployment environment, and pull it for local development:

```bash
openssl rand -base64 32
vercel env add TOOL_APPROVAL_SECRET
vercel env pull
```

Use the same value across instances that handle the same approval conversation. Keep it server-side and never prefix it with `NEXT_PUBLIC_`.

Create `lib/tool-approval.ts` to require the setting:

```typescript
export function getToolApprovalSecret(): string {
  const secret = process.env.TOOL_APPROVAL_SECRET;
  if (!secret) {
    throw new Error('Set TOOL_APPROVAL_SECRET');
  }
  return secret;
}
```

Then create `lib/agent.ts`:

```typescript
import {
  isStepCount,
  ToolLoopAgent,
  type InferAgentUIMessage,
} from 'ai';
import { shopifyTools } from './tools';
import { getToolApprovalSecret } from './tool-approval';

export const shopAgent = new ToolLoopAgent({
  model: process.env.AGENT_MODEL ?? 'anthropic/claude-sonnet-5.5',
  instructions: `You are a store operations assistant
for a Shopify store.
Look up products and orders before answering questions about them.
Quote prices with their currency. When you change a product,
say exactly what you changed. Do not retry a denied tool call.`,
  tools: shopifyTools,
  toolApproval: {
    updateProduct: 'user-approval',
  },
  experimental_toolApprovalSecret: getToolApprovalSecret(),
  stopWhen: isStepCount(10),
});

export type ShopAgentUIMessage = InferAgentUIMessage<typeof shopAgent>;
```

The agent asks for approval before running `updateProduct`. The SDK signs the tool name, call ID, and arguments, then checks that signature when the decision returns.

Signatures do not identify the approver or prevent a valid approval from being replayed. Keep staff authorization on the route and use server-owned conversation state to bind sensitive approvals to a user and record completed writes.

`isStepCount(10)` caps one run at ten model steps. The model ID comes from `AGENT_MODEL` when it's set, so you can switch models without a code change.

### 8\. Add the route handler

Connect `getStoreOperator` to your authentication library's server-side session lookup. It must check permission for the store in `SHOPIFY_STORE_DOMAIN`. The adapter below deliberately throws until that integration exists.

Create `lib/staff-session.ts`:

```typescript
export type StoreOperator = {
  id: string;
  canManageStore: boolean;
};

// Connect this to your auth library's server-side session lookup.
// Return null when signed out. Check permission for the store in
// SHOPIFY_STORE_DOMAIN before setting canManageStore to true.
// Never read the user ID or permission from the request body.
export async function getStoreOperator(): Promise<
  StoreOperator | null
> {
  throw new Error('Connect getStoreOperator to your auth library');
}
```

Then add `app/api/agent/route.ts`:

```typescript
import { createAgentUIStreamResponse } from 'ai';
import { shopAgent } from '@/lib/agent';
import { getStoreOperator } from '@/lib/staff-session';

export async function POST(request: Request) {
  const operator = await getStoreOperator();
  if (!operator) {
    return Response.json({ error: 'Sign in first' }, { status: 401 });
  }
  if (!operator.canManageStore) {
    return Response.json({ error: 'Access denied' }, { status: 403 });
  }

  const { messages } = await request.json();
  return createAgentUIStreamResponse({
    agent: shopAgent,
    uiMessages: messages,
    abortSignal: request.signal,
  });
}
```

### 9\. Build the chat UI

Replace `app/page.tsx` with a minimal chat that renders text and approval requests:

```tsx
'use client';

import { useChat } from '@ai-sdk/react';
import {
  DefaultChatTransport,
  isToolUIPart,
  lastAssistantMessageIsCompleteWithApprovalResponses as shouldContinue,
} from 'ai';
import { useState } from 'react';
import type { ShopAgentUIMessage } from '@/lib/agent';

export default function Page() {
  const [input, setInput] = useState('');
  const {
    messages,
    sendMessage,
    addToolApprovalResponse,
    status,
    error,
  } =
    useChat<ShopAgentUIMessage>({
      transport: new DefaultChatTransport({ api: '/api/agent' }),
      sendAutomaticallyWhen: shouldContinue,
    });

  const busy = status === 'submitted' || status === 'streaming';

  return (
    <main>
      {messages.map((message) => (
        <div key={message.id}>
          {message.parts.map((part, i) => {
            if (part.type === 'text') {
              return <p key={i}>{part.text}</p>;
            }
            if (
              part.type === 'tool-updateProduct' &&
              part.state === 'approval-requested'
            ) {
              return (
                <div key={i}>
                  <pre style={{ whiteSpace: 'pre-wrap' }}>
                    {JSON.stringify(part.input, null, 2)}
                  </pre>
                  <button
                    disabled={busy}
                    onClick={() =>
                      addToolApprovalResponse({
                        id: part.approval.id,
                        approved: true,
                      })
                    }
                  >
                    Approve
                  </button>
                  <button
                    disabled={busy}
                    onClick={() =>
                      addToolApprovalResponse({
                        id: part.approval.id,
                        approved: false,
                      })
                    }
                  >
                    Deny
                  </button>
                </div>
              );
            }
            if (isToolUIPart(part)) {
              if (part.state === 'output-error') {
                return (
                  <p key={i}>
                    The tool could not complete the request.
                  </p>
                );
              }
              if (part.state === 'output-denied') {
                return <p key={i}>Tool request denied.</p>;
              }
            }
            return null;
          })}
        </div>
      ))}
      {busy && <p role="status">Working...</p>}
      {error && (
        <p role="alert">
          The request failed. Check your sign-in and try again.
        </p>
      )}
      <form
        onSubmit={(event) => {
          event.preventDefault();
          if (!input.trim() || busy) return;
          void sendMessage({ text: input });
          setInput('');
        }}
      >
        <input
          aria-label="Message"
          value={input}
          onChange={(event) => setInput(event.target.value)}
          disabled={busy}
        />
        <button type="submit" disabled={busy || !input.trim()}>
          Send
        </button>
      </form>
    </main>
  );
}
```

The page shows each proposed product change with **Approve** and **Deny** buttons. It sends the staff member's decision to the route.

The chat also shows request and tool failures. Empty submissions and overlapping sends are disabled while a response is in progress.

### 10\. Run it locally

Start the dev server:

**pnpm**

```bash
pnpm dev
```

**yarn**

```bash
yarn dev
```

**npm**

```bash
npm run dev
```

**bun**

```bash
bun run dev
```

Open `http://localhost:3000` and try these prompts:

- Which active products have fewer than 5 units in stock?
  
- What's the fulfillment status of order #1001?
  
- Rename the Acme Mug to "Acme Mug (Restocked)".
  

Confirm that the third prompt shows **Approve** and **Deny** before the product changes. Use a test product in the development store, then check that denying the request leaves it unchanged.

Your staff session controls route access. `.env.local` holds the OIDC token for Connect requests through the Development link.

### 11\. Deploy

```bash
vercel --prod
```

On Vercel, the SDK reads the deployment's OIDC token automatically. Open the connector's **Observability** tab in the Connect dashboard to see the connector's token requests, which you can filter by project and environment.

## Add-on: Give the agent a persistent workspace with Vercel Drives

Compare this week's vendors with saved catalog exports. [Vercel Drives](https://vercel.com/docs/sandbox/concepts/drives) preserve those snapshots across sessions for analysis in [Vercel Sandbox](https://vercel.com/docs/sandbox).

The function keeps the Shopify token and writes catalog data to the drive. Generated commands get a read-only mount and no outbound network access.

Install the Sandbox SDK:

**pnpm**

```bash
pnpm add @vercel/sandbox
```

**yarn**

```bash
yarn add @vercel/sandbox
```

**npm**

```bash
npm install @vercel/sandbox
```

**bun**

```bash
bun add @vercel/sandbox
```

### Write to and read from the drive

Create `lib/workspace.ts`:

```typescript
import { posix } from 'node:path';
import { Drive, Sandbox } from '@vercel/sandbox';

const DRIVE_NAME =
  process.env.WORKSPACE_DRIVE ?? 'shopify-agent-workspace';
const DRIVE_REGION = process.env.WORKSPACE_REGION ?? 'iad1';

function getWorkspaceDrive() {
  return Drive.getOrCreate({
    name: DRIVE_NAME,
    region: DRIVE_REGION,
  });
}

export async function writeToWorkspace(
  path: string,
  content: string,
) {
  const drive = await getWorkspaceDrive();
  const sandbox = await Sandbox.create({
    region: drive.region,
    mounts: { '/workspace': drive },
    networkPolicy: 'deny-all',
    timeout: 60_000,
    persistent: false,
  });

  try {
    const fullPath = `/workspace/${path}`;
    const directory = await sandbox.runCommand({
      cmd: 'mkdir',
      args: ['-p', posix.dirname(fullPath)],
      timeoutMs: 10_000,
    });
    if (directory.exitCode !== 0) {
      throw new Error('Could not create the catalog directory.');
    }
    await sandbox.writeFiles([
      { path: fullPath, content: Buffer.from(content) },
    ]);
  } finally {
    await sandbox.stop();
  }
}

export async function runInWorkspace(command: string) {
  const drive = await getWorkspaceDrive();
  const sandbox = await Sandbox.create({
    region: drive.region,
    mounts: { '/workspace': drive.snapshot() },
    networkPolicy: 'deny-all',
    timeout: 60_000,
    persistent: false,
  });

  try {
    const signal = AbortSignal.timeout(45_000);
    const process = await sandbox.runCommand({
      cmd: 'bash',
      args: ['-c', command],
      cwd: '/workspace',
      detached: true,
      timeoutMs: 30_000,
      signal,
    });
    const output = { stdout: '', stderr: '' };
    const limits = { stdout: 20_000, stderr: 5_000 };
    let truncated = false;

    for await (const log of process.logs({ signal })) {
      const remaining =
        limits[log.stream] - output[log.stream].length;
      output[log.stream] += log.data.slice(0, remaining);

      if (log.data.length > remaining) {
        truncated = true;
        await process.kill('SIGKILL');
        break;
      }
    }

    const result = await process.wait({ signal });
    return { exitCode: result.exitCode, ...output, truncated };
  } finally {
    await sandbox.stop();
  }
}
```

Each drive allows one read-write mount at a time, so run catalog exports one at a time. The writer releases its mount when the sandbox stops. Multiple agent runs can read snapshots concurrently.

Both helpers create the sandbox in the drive's region and discard its temporary filesystem on shutdown. The analysis command has a 30-second deadline.

The helper stops the command if stdout exceeds 20,000 characters or stderr exceeds 5,000, and reports truncated output. It consumes logs incrementally instead of collecting the complete output before slicing it.

### Add the workspace tools

Create `lib/workspace-tools.ts`:

```typescript
import { randomUUID } from 'node:crypto';
import { tool } from 'ai';
import { z } from 'zod';
import { READ_PRODUCT_SCOPES, shopifyGraphQL } from './shopify';
import { runInWorkspace, writeToWorkspace } from './workspace';

const CATALOG_PAGE = /* GraphQL */ `
  query CatalogPage($after: String) {
    products(first: 250, after: $after) {
      nodes {
        id
        title
        status
        vendor
        productType
        totalInventory
        updatedAt
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

type CatalogPage = {
  products: {
    nodes: Array<Record<string, unknown>>;
    pageInfo: { hasNextPage: boolean; endCursor: string | null };
  };
};

export const workspaceTools = {
  exportCatalog: tool({
    description:
      'Save a complete catalog of up to 5,000 products as a ' +
      'new JSON Lines file in /workspace/catalog. ' +
      'Run this before analyzing the catalog.',
    inputSchema: z.object({}),
    execute: async () => {
      const lines: string[] = [];
      let after: string | null = null;

      for (let page = 0; page < 20; page++) {
        const data: CatalogPage = await shopifyGraphQL<CatalogPage>(
          CATALOG_PAGE,
          { after },
          READ_PRODUCT_SCOPES,
        );
        for (const product of data.products.nodes) {
          lines.push(JSON.stringify(product));
        }

        const { hasNextPage, endCursor } = data.products.pageInfo;
        if (!hasNextPage) {
          const timestamp = new Date().toISOString();
          const path =
            `catalog/${timestamp}-${randomUUID()}.jsonl`;
          const content = lines.length ? lines.join('\n') + '\n' : '';
          await writeToWorkspace(path, content);
          return {
            path: `/workspace/${path}`,
            products: lines.length,
            complete: true,
          };
        }

        if (!endCursor || endCursor === after) {
          throw new Error('Catalog pagination did not advance.');
        }
        after = endCursor;
      }

      throw new Error(
        'Catalog exceeds this example\'s 5,000-product limit. ' +
        'No snapshot was saved. Use a Shopify bulk export.',
      );
    },
  }),

  runWorkspaceCommand: tool({
    description:
      'Run bash against the read-only /workspace mount. ' +
      'Network access is blocked. Commands stop after 30 seconds ' +
      'or when their output exceeds the limit. ' +
      'Use this to inspect or compare catalog snapshots.',
    inputSchema: z.object({
      command: z.string().describe('A command using /workspace'),
    }),
    execute: async ({ command }) => {
      try {
        return await runInWorkspace(command);
      } catch (error) {
        return {
          error:
            error instanceof Error ? error.message : String(error),
          hint:
            'If the workspace is empty, call exportCatalog first.',
        };
      }
    },
  }),
};
```

Exports support up to 5,000 products and are saved only after the last page. Each export gets a unique path to preserve previous snapshots.

If products remain after 20 pages, the tool saves nothing and directs the agent to use a Shopify bulk export. Export the catalog before analysis; command failures return an error to the model.

### Register the tools with the agent

Update `lib/agent.ts`:

```typescript
import {
  isStepCount,
  ToolLoopAgent,
  type InferAgentUIMessage,
} from 'ai';
import { shopifyTools } from './tools';
import { getToolApprovalSecret } from './tool-approval';
import { workspaceTools } from './workspace-tools';

export const shopAgent = new ToolLoopAgent({
  model: process.env.AGENT_MODEL ?? 'anthropic/claude-sonnet-5.5',
  instructions: `You are a store operations assistant
for a Shopify store.
Look up products and orders before answering questions about them.
Quote prices with their currency. When you change a product,
say exactly what you changed. Do not retry a denied tool call.
For catalog-wide questions, export the catalog to the workspace
and analyze it with shell commands.`,
  tools: { ...shopifyTools, ...workspaceTools },
  toolApproval: {
    updateProduct: 'user-approval',
  },
  experimental_toolApprovalSecret: getToolApprovalSecret(),
  stopWhen: isStepCount(10),
});

export type ShopAgentUIMessage = InferAgentUIMessage<typeof shopAgent>;
```

Ask the agent to export the catalog, then ask a question that needs the full file, such as "How many products from each vendor are in today's snapshot?" On later days, ask it to compare two snapshots.

Each export or analysis starts a sandbox, which adds startup and execution time to the tool call. Drive storage charges depend on stored data. Review the [storage rates and included usage](https://vercel.com/kb/guide/vercel-drives#how-vercel-drives-pricing-works) when choosing how many snapshots to retain.

## Add-on: Review orders in the background with Vercel Daemons

Staff can tag an order `agent-review` in Shopify for a background review. The [Vercel Daemon](https://vercel.com/docs/daemons) polls for tagged orders and writes a model-generated note to each one.

Successful reviews receive an `agent-reviewed` tag. Orders the model flags for staff attention also receive `needs-human`.

The pending tag identifies work for the daemon without a separate queue. It provides no job lease, so more than one worker can process the same order.

Vercel Daemons is in public beta and runs only for production deployments. Run the worker locally with Docker because `vercel dev` does not start daemons.

### Write the worker

Create a `worker/` directory with its own `package.json`:

```json
{
  "name": "order-review-worker",
  "private": true,
  "type": "module",
  "dependencies": {
    "@vercel/connect": "^2.9.0",
    "ai": "^7.0.0",
    "zod": "^4.0.0"
  },
  "overrides": {
    "@vercel/oidc": "^4.0.7"
  }
}
```

The override enables file-based OIDC authentication for this worker's model calls. Daemons supply their token through `VERCEL_OIDC_TOKEN_FILE`, which version 4.0 and later of `@vercel/oidc` can read.

The AI Gateway dependency used here still pins version 3.2.0. Keep the override when generating the worker's lockfile so model calls can read the daemon token.

Create `worker/worker.js`:

```javascript
import { setTimeout as sleep } from 'node:timers/promises';
import { getToken } from '@vercel/connect';
import { generateText, Output } from 'ai';
import { z } from 'zod';

const CONNECTOR =
  process.env.SHOPIFY_CONNECTOR ?? 'shopify/acme-shopify';
const STORE_DOMAIN = process.env.SHOPIFY_STORE_DOMAIN;
const MODEL =
  process.env.AGENT_MODEL ?? 'anthropic/claude-sonnet-5.5';
const REVIEW_TAG = 'agent-review';

const shutdown = new AbortController();
process.on('SIGTERM', () => shutdown.abort());

async function shopify(query, variables) {
  const token = await getToken(CONNECTOR, {
    subject: { type: 'app' },
    scopes: ['read_orders', 'write_orders'],
  });
  const endpoint =
    `https://${STORE_DOMAIN}/admin/api/2026-10/graphql.json`;
  const response = await fetch(endpoint, {
    method: 'POST',
    headers: {
      'X-Shopify-Access-Token': token,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ query, variables }),
  });
  const body = await response.json();
  if (!response.ok || body.errors) {
    const details = JSON.stringify(body.errors ?? body);
    throw new Error(`Shopify error: ${details}`);
  }
  return body.data;
}

const ORDERS_TO_REVIEW = `
  query OrdersToReview($query: String!) {
    orders(first: 10, query: $query) {
      nodes {
        id
        name
        note
        displayFinancialStatus
        displayFulfillmentStatus
        totalPriceSet { shopMoney { amount currencyCode } }
        shippingAddress { countryCodeV2 }
        lineItems(first: 50) { nodes { title sku quantity } }
      }
    }
  }
`;

const CURRENT_NOTE = `
  query CurrentNote($id: ID!) {
    order(id: $id) { note }
  }
`;

const SAVE_NOTE = `
  mutation SaveNote($input: OrderInput!) {
    orderUpdate(input: $input) { userErrors { field message } }
  }
`;

const ADD_TAGS = `
  mutation AddTags($id: ID!, $tags: [String!]!) {
    tagsAdd(id: $id, tags: $tags) { userErrors { field message } }
  }
`;

const REMOVE_TAGS = `
  mutation RemoveTags($id: ID!, $tags: [String!]!) {
    tagsRemove(id: $id, tags: $tags) {
      userErrors { field message }
    }
  }
`;

function checkUserErrors(payload) {
  if (payload.userErrors.length) {
    throw new Error(JSON.stringify(payload.userErrors));
  }
}

async function reviewOrder(order) {
  const { output } = await generateText({
    model: MODEL,
    instructions:
      'You review Shopify orders for a fulfillment team. ' +
      'Write a two-sentence internal note covering unusual ' +
      'quantities, payment status, or destination. ' +
      'Set needsHuman when staff should look before shipping.',
    prompt: JSON.stringify(order),
    output: Output.object({
      schema: z.object({
        note: z.string(),
        needsHuman: z.boolean(),
      }),
    }),
  });

  // Read again after generation to include intervening edits.
  // A concurrent edit after this read can still be overwritten.
  const current = await shopify(CURRENT_NOTE, { id: order.id });
  if (!current.order) {
    throw new Error('Order is no longer available.');
  }

  const note = [current.order.note, `[Agent review] ${output.note}`]
    .filter(Boolean)
    .join('\n\n');

  const saved = await shopify(SAVE_NOTE, {
    input: { id: order.id, note },
  });
  checkUserErrors(saved.orderUpdate);

  const added = await shopify(ADD_TAGS, {
    id: order.id,
    tags: output.needsHuman
      ? ['agent-reviewed', 'needs-human']
      : ['agent-reviewed'],
  });
  checkUserErrors(added.tagsAdd);

  // Remove the pending tag only after both writes succeed.
  const removed = await shopify(REMOVE_TAGS, {
    id: order.id,
    tags: [REVIEW_TAG],
  });
  checkUserErrors(removed.tagsRemove);

  console.log(
    `Reviewed ${order.name} (needsHuman: ${output.needsHuman})`,
  );
}

async function main() {
  const region = process.env.VERCEL_REGION ?? 'local';
  console.log(`Order review worker started in ${region}`);

  while (!shutdown.signal.aborted) {
    try {
      const data = await shopify(ORDERS_TO_REVIEW, {
        query: `tag:${REVIEW_TAG}`,
      });

      for (const order of data.orders.nodes) {
        if (shutdown.signal.aborted) break;
        try {
          await reviewOrder(order);
        } catch (error) {
          console.error(`Review failed for ${order.name}`, error);
        }
      }
    } catch (error) {
      console.error(error);
    }

    // SIGTERM interrupts the wait after the current order finishes.
    await sleep(60_000, undefined, {
      signal: shutdown.signal,
    }).catch(() => {});
  }

  console.log('Received SIGTERM, shutting down');
}

main();
```

The worker saves the note and checks for errors before adding the outcome tags. It removes `agent-review` only after both writes succeed. Each order has its own error handler so a failed review does not skip the rest of the selected batch.

Retries can duplicate notes after a partial success or restart. Before production use, track reviews by durable ID and skip completed work.

The worker reads the note again after generation to include intervening staff edits. The update still replaces the entire note field, so coordinate shared edits or store agent reviews separately to prevent concurrent changes from being overwritten.

On `SIGTERM`, the abort signal ends the 60-second wait immediately, and the loop exits after the order it's working on. If a review outlasts the 30-second default grace period, raise `terminationGracePeriodSeconds` in the daemon's configuration.

Generate the lockfile that the `Dockerfile` installs from:

```bash
cd worker
npm install
cd ..
```

### Add a Dockerfile

Create `worker/Dockerfile`:

```dockerfile
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "worker.js"]
```

### Declare the daemon

Add the `daemons` key to `vercel.json` at the project root:

```json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "daemons": {
    "order-review": {
      "root": "worker/",
      "entrypoint": "Dockerfile",
      "instances": { "iad1": 1 },
      "resources": { "requests": { "memory": "1Gi" } }
    }
  }
}
```

The configuration requests one instance, but deployments can briefly overlap old and new workers. Reading the pending tag and updating it are separate operations, so both workers can select the same order.

For multiple consumers, use [Vercel Queues in poll mode](https://vercel.com/docs/queues/poll-mode) with a visibility timeout that covers processing. Review writes must tolerate repeat delivery.

Setting `resources.requests.memory` to `1Gi` gives the worker 1 vCPU and halves its Provisioned Memory usage compared with the 2 GiB default.

On Hobby, team daemons share 420 GB-hours of Provisioned Memory and 5 Active CPU-hours each month. Idle time counts toward memory usage, so an always-on 1 GiB instance exhausts that allowance in about two and a half weeks.

Reaching either quota stops the team's daemons until the next month or a plan upgrade. Use Hobby to try the worker; budget for Pro usage if it needs to stay online throughout the month.

### Deploy and watch the logs

```bash
vercel --prod
```

After the production deployment is ready, select `order-review` under **Daemons** in the project dashboard to view its instance and logs.

Tag a test order with `agent-review` in Shopify. The worker checks for tagged orders between batches, with a 60-second wait after each batch. After a successful review, confirm that the log names the order and its note includes the new review.

The daemon uses the same Shopify connector as your app, so its token requests appear in the same Observability tab under the production environment.

## Add-on: Let shoppers ask about their own orders with the Customer Accounts MCP server

The staff agent uses your app's authority. Shopify's [Customer Accounts MCP server](https://shopify.dev/docs/apps/build/customer-accounts-mcp) instead gives a shopper access to their own orders and account after authorization.

Vercel Connect manages the OAuth flow and stores each shopper's authorization under the user ID you supply. The MCP adapter gets the shopper's token through Connect and can reuse it from the SDK's process cache. Your application does not need a token database.

### Set up the Shopify app for customer accounts

The Customer Accounts MCP server has its own requirements:

- Your store has a custom domain, such as `shop.acme.com`.
  
- Your app meets Shopify's protected customer data requirements, with access requested for `name`, `email`, `phone`, and `address`.
  
- Your app is set up for customer accounts authentication.
  

Use the directory containing your Shopify app configuration. For an app created in the Dev Dashboard, run `shopify app config link` there first.

Merge the customer scopes into the existing scope list and add Connect's callback URI. Keep other scopes your app needs, including `write_orders` if the order-review worker uses this app:

```toml
[access_scopes]
scopes = """read_products,write_products,read_orders,\
customer_read_customers,customer_read_orders"""

[customer_authentication]
redirect_uris = ["https://connect.vercel.com/callback"]
```

Run `shopify app deploy` to release the configuration, then install or update the app on the store to grant the required access. For a development store, `shopify app dev` applies the configuration during the development session.

### Create the connector

Create a connector from your storefront domain. Connect reads the store's OpenID Connect discovery document to find the authorization and token endpoints:

```bash
vercel connect create shop.acme.com --name acme-customer-accounts
vercel connect attach oauth/acme-customer-accounts
```

Enter your Shopify app's client ID and leave the client secret blank. This app configuration uses a public OAuth client with PKCE.

Use the UID printed by `create` for the connector link and `CUSTOMER_ACCOUNTS_CONNECTOR`. Confirm it matches the UID in the attach command.

Add the storefront domain, and the connector UID if it differs from `oauth/acme-customer-accounts`, then pull the variables:

```bash
vercel env add SHOPIFY_STOREFRONT_DOMAIN
vercel env add CUSTOMER_ACCOUNTS_CONNECTOR
vercel env pull
```

Install the AI SDK MCP client:

**pnpm**

```bash
pnpm add @ai-sdk/mcp@^2
```

**yarn**

```bash
yarn add @ai-sdk/mcp@^2
```

**npm**

```bash
npm install @ai-sdk/mcp@^2
```

**bun**

```bash
bun add @ai-sdk/mcp@^2
```

### Identify the shopper

Connect stores one grant per user ID, so the ID has to come from your own sign-in session. Create `lib/session.ts` and connect it to your auth library:

```typescript
// Connect this to your auth library's server-side session lookup.
// Return the signed-in shopper's stable ID.
// Never take it from the request body.
export async function getShopperId(): Promise<string | null> {
  throw new Error('Connect getShopperId to your sign-in session');
}
```

An ID taken from the request body would let anyone use another shopper's grant.

### Find the MCP endpoint

Read the store's discovery document to find its Customer Accounts MCP endpoint:

```typescript
import type { UIMessage } from 'ai';

const STOREFRONT_DOMAIN =
  process.env.SHOPIFY_STOREFRONT_DOMAIN; // shop.acme.com

export type AccountUIMessage = UIMessage<
  unknown,
  { consent: { url: string } }
>;

let mcpUrl: Promise<string> | undefined;

// Cache the discovered endpoint per instance.
// Retry discovery on the next request if it fails.
export function getCustomerAccountsMcpUrl(): Promise<string> {
  mcpUrl ??= (async () => {
    if (!STOREFRONT_DOMAIN) {
      throw new Error(
        'Set SHOPIFY_STOREFRONT_DOMAIN, for example shop.acme.com',
      );
    }
    const response = await fetch(
      `https://${STOREFRONT_DOMAIN}/.well-known/customer-account-api`,
    );
    if (!response.ok) {
      throw new Error(
        `Customer Account API discovery returned ${response.status}`,
      );
    }
    const { mcp_api } = (await response.json()) as { mcp_api?: string };
    if (!mcp_api) {
      throw new Error(
        'The store does not publish a Customer Accounts MCP endpoint',
      );
    }
    return mcp_api;
  })().catch((error) => {
    mcpUrl = undefined;
    throw error;
  });
  return mcpUrl;
}
```

`AccountUIMessage` adds a `consent` data part, which the route uses to send the shopper a sign-in link through the chat.

### Add the route

Use the step 7 signing helper for the customer route below:

```typescript
import { createMCPClient, type MCPClient } from '@ai-sdk/mcp';
import {
  NoValidTokenError,
  startAuthorization,
} from '@vercel/connect';
import {
  connectAuthProvider,
  getConsentChallenge,
} from '@vercel/connect/ai-sdk';
import {
  createAgentUIStreamResponse,
  createUIMessageStream,
  createUIMessageStreamResponse,
  isStepCount,
  ToolLoopAgent,
} from 'ai';
import {
  type AccountUIMessage,
  getCustomerAccountsMcpUrl,
} from '@/lib/customer-accounts';
import { getShopperId } from '@/lib/session';
import { getToolApprovalSecret } from '@/lib/tool-approval';

const CONNECTOR =
  process.env.CUSTOMER_ACCOUNTS_CONNECTOR ??
  'oauth/acme-customer-accounts';

function hasNoValidTokenError(error: unknown): boolean {
  let cause = error;
  for (let depth = 0; depth < 16; depth++) {
    if (cause instanceof NoValidTokenError) return true;
    if (
      typeof cause !== 'object' ||
      cause === null ||
      !('cause' in cause)
    ) {
      return false;
    }
    cause = cause.cause;
  }
  return false;
}

export async function POST(request: Request) {
  const shopperId = await getShopperId();
  if (!shopperId) {
    return Response.json({ error: 'Sign in first' }, { status: 401 });
  }

  const { messages } = await request.json();
  const tokenParams = {
    subject: { type: 'user' as const, id: shopperId },
    scopes: ['customer-account-mcp-api:full'],
  };
  const redirectUrl = new URL('/account', request.url).toString();
  let mcpClient: MCPClient | undefined;

  try {
    mcpClient = await createMCPClient({
      transport: {
        type: 'http',
        url: await getCustomerAccountsMcpUrl(),
        authProvider: connectAuthProvider(
          CONNECTOR,
          tokenParams,
          { consent: 'eager', redirectUrl },
        ),
      },
    });

    // Read-only tools run directly. Ask for approval for other tools.
    const definitions = await mcpClient.listTools();
    const readOnly = new Set(
      definitions.tools
        .filter((definition) => definition.annotations?.readOnlyHint)
        .map((definition) => definition.name),
    );

    const client = mcpClient;
    const agent = new ToolLoopAgent({
      model: process.env.AGENT_MODEL ?? 'anthropic/claude-sonnet-5.5',
      instructions:
        "You help a signed-in shopper with their own orders " +
        "and account. Use the tools to look things up, and " +
        "never guess an order's status. " +
        "Do not retry a denied tool call.",
      tools: client.toolsFromDefinitions(definitions),
      toolApproval: ({ toolCall }) =>
        readOnly.has(toolCall.toolName)
          ? 'not-applicable'
          : 'user-approval',
      experimental_toolApprovalSecret: getToolApprovalSecret(),
      stopWhen: isStepCount(8),
    });

    return await createAgentUIStreamResponse({
      agent,
      uiMessages: messages,
      abortSignal: request.signal,
      onEnd: () => client.close(),
    });
  } catch (error) {
    try {
      await mcpClient?.close();
    } catch {
      // Preserve the original error, including any consent challenge.
    }

    // Send the consent link when the shopper needs to connect.
    let consentUrl = getConsentChallenge(error)?.url;
    if (!consentUrl && hasNoValidTokenError(error)) {
      const authorization = await startAuthorization(
        CONNECTOR,
        tokenParams,
        { callbackUrl: redirectUrl },
      );
      consentUrl = authorization.url;
    }
    const url = consentUrl;
    if (!url) throw error;

    const stream = createUIMessageStream<AccountUIMessage>({
      execute: ({ writer }) => {
        writer.write({
          type: 'data-consent',
          data: { url },
        });
      },
    });
    return createUIMessageStreamResponse({ stream });
  }
}
```

The route requests a customer token with the signed-in user's ID and Shopify's MCP scope. Eager consent checks authorization before any MCP traffic.

During MCP setup, the route returns a consent link when Connect requires authorization or reports that no valid token remains. Both paths authorize the same shopper and scope, then return the shopper to `/account`.

This public client receives no refresh token. Expired access requires another browser authorization, which can reuse an active Shopify customer session.

The route asks for approval except when Shopify marks a tool read-only. For other MCP servers, define your own tool allowlist and approval policy.

Each request opens its own MCP client. The route closes it when the stream ends or response creation fails, and preserves the original error if cleanup also fails.

### Add the account page

Create `app/account/page.tsx`:

```tsx
'use client';

import { useChat } from '@ai-sdk/react';
import {
  DefaultChatTransport,
  isToolUIPart,
  lastAssistantMessageIsCompleteWithApprovalResponses as shouldContinue,
} from 'ai';
import { useState } from 'react';
import type { AccountUIMessage } from '@/lib/customer-accounts';

export default function AccountPage() {
  const [input, setInput] = useState('');
  const {
    messages,
    sendMessage,
    addToolApprovalResponse,
    status,
    error,
  } =
    useChat<AccountUIMessage>({
      transport: new DefaultChatTransport({ api: '/api/account' }),
      sendAutomaticallyWhen: shouldContinue,
    });

  const busy = status === 'submitted' || status === 'streaming';

  return (
    <main>
      {messages.map((message) => (
        <div key={message.id}>
          {message.parts.map((part, i) => {
            if (part.type === 'text') {
              return <p key={i}>{part.text}</p>;
            }
            if (part.type === 'data-consent') {
              return (
                <a key={i} href={part.data.url}>
                  Connect your Shopify account
                </a>
              );
            }
            if (
              part.type === 'dynamic-tool' &&
              part.state === 'approval-requested'
            ) {
              return (
                <div key={i}>
                  <p>Allow {part.toolName}?</p>
                  <pre style={{ whiteSpace: 'pre-wrap' }}>
                    {JSON.stringify(part.input, null, 2)}
                  </pre>
                  <button
                    disabled={busy}
                    onClick={() =>
                      addToolApprovalResponse({
                        id: part.approval.id,
                        approved: true,
                      })
                    }
                  >
                    Allow
                  </button>
                  <button
                    disabled={busy}
                    onClick={() =>
                      addToolApprovalResponse({
                        id: part.approval.id,
                        approved: false,
                      })
                    }
                  >
                    Deny
                  </button>
                </div>
              );
            }
            if (isToolUIPart(part)) {
              if (part.state === 'output-error') {
                return (
                  <p key={i}>
                    The tool could not complete the request.
                  </p>
                );
              }
              if (part.state === 'output-denied') {
                return <p key={i}>Tool request denied.</p>;
              }
            }
            return null;
          })}
        </div>
      ))}
      {busy && <p role="status">Working...</p>}
      {error && (
        <p role="alert">
          The request failed. Check your sign-in and try again.
        </p>
      )}
      <form
        onSubmit={(event) => {
          event.preventDefault();
          if (!input.trim() || busy) return;
          void sendMessage({ text: input });
          setInput('');
        }}
      >
        <input
          aria-label="Message"
          value={input}
          onChange={(event) => setInput(event.target.value)}
          disabled={busy}
        />
        <button type="submit" disabled={busy || !input.trim()}>
          Send
        </button>
      </form>
    </main>
  );
}
```

Customer Accounts tools arrive as `dynamic-tool` parts because their names are discovered at runtime. The page handles their approval requests and errors alongside the consent link.

### Try it

Sign in to your app as a shopper, open `/account`, and ask "Where's my most recent order?" The first reply is a **Connect your Shopify account** link. Approve access on Shopify's sign-in page, and Connect returns you to `/account`. Ask again, and the agent answers from the Customer Accounts MCP server.

Handle disconnect on the server using the connector UID and the shopper ID from the authenticated session. Connect removes its stored authorization and clears the current process's token cache.

Whether previously issued tokens stop working immediately depends on the connector and Shopify's revocation support:

```typescript
import { revokeToken } from '@vercel/connect';

const CONNECTOR =
  process.env.CUSTOMER_ACCOUNTS_CONNECTOR ?? 'oauth/acme-customer-accounts';

// Call from a server handler.
// Get the shopper ID from its authenticated session.
export async function disconnectShopper(shopperId: string) {
  await revokeToken(CONNECTOR, {
    subject: { type: 'user', id: shopperId },
  });
}
```

## Best practices

- Require server-verified approval for staff mutations. Restrict the background worker to its intended note and tag updates.
  
- Request the scopes each tool needs. Configure installed Shopify permissions separately, since requested scopes may not narrow the issued token.
  
- Keep test stores on separate connectors. Every environment linked to a connector reaches the same store, so link the development-store connector only to Development and Preview.
  
- Set `AGENT_MODEL` and redeploy to switch models.
  
- Keep the Admin API version pinned and schedule upgrades before support ends. Shopify supports stable versions for at least 12 months.
  

## Troubleshooting

### Connect returns `403` with code `forbidden`

Check that the project is linked to the connector and the link includes the request's environment. Run `vercel connect attach shopify/acme-shopify` from the project directory. Local requests require a Development link.

### Connect returns `404` with code `not_found`

`vercel connect list --all-projects` includes connectors that aren't linked to the current project. Check the team and UID before setting `SHOPIFY_CONNECTOR`, then attach the connector to the project.

### Requests fail locally after working earlier

The development OIDC token in `.env.local` has expired. Run `vercel env pull` again.

### Shopify returns an access denied error

Check the scope or protected-data permission named in Shopify's error. Update the app's scopes and approve the changed installation permissions when needed. For order or address data, also check protected customer data access.

### Daemon logs show `GatewayAuthenticationError`

Run `npm ls @vercel/oidc` from `worker/` to inspect the resolved dependencies. If AI Gateway uses version 3.x, add the override and regenerate the lockfile with `npm install` before redeploying.

If the dependency is already 4.x, check the Gateway authentication configuration. This error can have more than one cause.

### The daemon doesn't start on a preview deployment

Daemons run only in production during the beta. For local testing, create `.env.docker` at the project root with `SHOPIFY_STORE_DOMAIN` and `VERCEL_OIDC_TOKEN` from `.env.local`. Include `SHOPIFY_CONNECTOR` if you used a custom connector UID.

Leave out quotation marks because Docker reads the values literally. Keep this file out of version control, then build and run the worker:

```bash
docker build -t order-review ./worker
docker run --rm --env-file .env.docker order-review
```

The development OIDC token authenticates requests to Connect and AI Gateway. Pull a new token into `.env.local` and update `.env.docker` if it expires.

## Related resources and next steps

- Plan [connector access and token usage](https://vercel.com/kb/guide/vercel-connect) across projects.
  
- Check [token requests and SDK errors](https://vercel.com/docs/connect/ts-sdk-reference) when integrating Connect.
  
- Configure [MCP authentication and consent](https://vercel.com/docs/connect/frameworks/ai-sdk-and-mcp).
  
- Set up [Shopify Customer Accounts MCP](https://shopify.dev/docs/apps/build/customer-accounts-mcp).
  
- Manage [drive snapshots, forks, and storage limits](https://vercel.com/kb/guide/vercel-drives).
  
- Configure [daemon scaling and graceful shutdown](https://vercel.com/kb/guide/vercel-daemons).
  
- Add [durable agent execution with WorkflowAgent](https://vercel.com/kb/guide/ai-gateway-and-ai-sdk).
  

## Frequently asked questions

### Do I need to store a Shopify access token to build this agent?

No. Set the store domain and connector ID. Connect uses the deployment's OIDC identity to fetch tokens and cache them in server memory.

### How does the agent avoid changing products without permission?

The chat shows the proposed product change and returns the staff member's decision. The SDK checks the signed approval before executing the tool, and the route checks staff access on each request. For one-time approval, the application must also track completion and bind the conversation to the signed-in user.

### How many Vercel Connect token requests does the agent make?

The count depends on which tools run and whether their tokens are cached. Different scopes use separate cache entries, and new instances, concurrent misses, expiry, or rejected-token retries can trigger additional requests.

[Connect pricing](https://vercel.com/docs/connect/pricing) includes 500 token requests each month on Hobby. Pro charges $3.00 per 1,000 requests.

### When should I add Vercel Drives to a Shopify agent?

Add a drive when the agent needs data that's larger than a tool result or needs to persist between conversations, such as daily catalog snapshots it compares over time. The drive stores the files, and each analysis runs in a short-lived sandbox that mounts a read-only snapshot of it.

### Why does the order review daemon need an `@vercel/oidc` override?

The override lets the daemon read its OIDC token file. AI Gateway pins `@vercel/oidc` 3.2.0, but file-based tokens require version 4.0 or later.

### Can a Shopify agent use Shopify's MCP servers with Vercel Connect?

Yes, for agents that act for shoppers. The Customer Accounts MCP server needs a per-shopper OAuth grant, and `connectAuthProvider` gets that grant from Vercel Connect for each signed-in user. For a store operations agent, the Admin GraphQL API is the better fit, because none of Shopify's MCP servers gives staff access to the store's products and orders.

## Related Documentation

- [AI SDK](/docs/ai-sdk)
- [Vercel Connect](https://vercel.com/docs/connect)

## Related Resources

- [Build a Shopify operations agent with Vercel Connect](/kb/prompts/build-shopify-operations-agent): Create a staff agent that searches products and checks orders through Vercel Connect, and asks for approval before it changes your catalog.
- [Deploy a Shopify storefront with an AI assistant](/kb/guide/deploy-shopify-ai-storefront): Deploy an eve shopping agent with Vercel Shop to find Shopify products, manage carts, and answer store questions.

## More AI SDK guides

- [What is an LLM Tool?](/kb/guide/what-is-an-llm-tool): Learn what tools are, how tool calling works, and how you can use them to build agents.
- [Build an AI Chat Agent with Weather API Tool Calling](/kb/guide/build-ai-agent-weather-api): Build an intelligent conversational agent that fetches real-time weather data using the AI SDK, tool calling, and a backend weather API powered by Express, FastAPI or Nitro
- [Build AI agents with AI Gateway and AI SDK](/kb/guide/ai-gateway-and-ai-sdk): Build AI agents on Vercel with AI Gateway and AI SDK, then make them reliable, capable, and durable with Sandbox, Chat SDK, Vercel Connect, and Workflow.

## Reference Architecture

Choose a stack and work through the decisions behind it, with curated documentation, guides, templates, and courses to help you build.

- [Agent stack](/kb/architecture/agents): Build and operate agents with models, tools, and durable workflows.
- [Web applications](/kb/architecture/web): Choose rendering, data, authentication, and caching approaches.
- [Backends](/kb/architecture/backends): Choose a runtime, connect services, and run background work.

[Browse all stacks](/kb/architecture)

## Build with prompts

Build an app, extend an agent, or improve your project with ready-to-use prompts for your coding agent.

[Browse the prompt library](/kb/prompts) · [Read the Markdown index](/kb/prompts.md)
