> ## Documentation Index
> Fetch the complete documentation index at: https://developer.box.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build an AI-powered DAM system with Box

> Extract brand metadata with Box AI, move files through a review lifecycle, invite vendors to a Draft folder, and add approved files to a Box Hub.

export const AgentPromptBuilder = ({stacks = [], rootId = "agent-prompt-root"}) => {
  const languages = Array.isArray(stacks) ? stacks : [];
  const [language, setLanguage] = useState(languages[0]?.language || "");
  const [framework, setFramework] = useState(languages[0]?.frameworks?.[0]?.name || "");
  const activeLanguage = languages.find(entry => entry.language === language) || languages[0];
  const frameworks = activeLanguage?.frameworks || [];
  const activeFramework = frameworks.find(entry => entry.name === framework)?.name || frameworks[0]?.name || "";
  useEffect(() => {
    if (!activeLanguage?.language || !activeFramework) return;
    const root = document.getElementById(rootId);
    if (!root) return;
    const key = `${activeLanguage.language}||${activeFramework}`;
    const panels = root.querySelectorAll("[data-agent-stack]");
    panels.forEach(panel => {
      const match = panel.getAttribute("data-agent-stack") === key;
      panel.hidden = !match;
      panel.style.display = match ? "" : "none";
    });
  }, [activeLanguage?.language, activeFramework, rootId]);
  if (languages.length === 0) {
    return <div className="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-600/20 dark:text-red-300">
        AgentPromptBuilder needs a non-empty <code>stacks</code> prop.
      </div>;
  }
  const selectClassName = "w-full min-w-[10rem] appearance-none rounded-lg border border-gray-200 bg-white px-3 py-2 pr-9 text-sm font-medium text-gray-900 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-400/30 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:ring-gray-500/30";
  const handleLanguageChange = event => {
    const next = languages.find(entry => entry.language === event.target.value) || languages[0];
    setLanguage(next.language);
    setFramework(next.frameworks?.[0]?.name || "");
  };
  return <>
      <div aria-hidden="true" className="text-gray-500 dark:text-gray-400">
        <Icon icon="sparkles" size={24} />
      </div>

      <h3 className="mt-4 mb-0 text-base font-semibold text-gray-900 dark:text-white">
        Build this tutorial with your coding agent
      </h3>
      <p className="mt-1 mb-0 text-base leading-6 text-gray-600 dark:text-gray-400">
        Choose your stack, then copy this ready-to-use prompt into Codex, Claude Code, Cursor, or
        another coding agent.
      </p>

      <div className="mt-5 flex flex-col gap-4 sm:flex-row">
        <div className="min-w-0 flex-1">
          <label htmlFor="agent-prompt-language" className="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-200">
            Programming language
          </label>
          <div className="relative">
            <select id="agent-prompt-language" value={activeLanguage.language} onChange={handleLanguageChange} className={selectClassName}>
              {languages.map(entry => <option key={entry.language} value={entry.language}>
                  {entry.language}
                </option>)}
            </select>
            <span aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-gray-500 dark:text-gray-400">
              <Icon icon="chevron-down" size={16} />
            </span>
          </div>
        </div>

        <div className="min-w-0 flex-1">
          <label htmlFor="agent-prompt-framework" className="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-200">
            Framework
          </label>
          <div className="relative">
            <select id="agent-prompt-framework" value={activeFramework} onChange={event => setFramework(event.target.value)} className={selectClassName}>
              {frameworks.map(entry => <option key={entry.name} value={entry.name}>
                  {entry.name}
                </option>)}
            </select>
            <span aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-gray-500 dark:text-gray-400">
              <Icon icon="chevron-down" size={16} />
            </span>
          </div>
        </div>
      </div>
    </>;
};

export const RelatedLinks = ({title, items = []}) => {
  const getBadgeClass = badge => {
    if (!badge) return "badge-default";
    const badgeType = badge.toLowerCase().replace(/\s+/g, "-");
    return `badge-${badge === "ガイド" ? "guide" : badgeType}`;
  };
  if (!items || items.length === 0) {
    return null;
  }
  return <div className="my-8">
      {}
      <h3 className="text-sm font-bold uppercase tracking-wider mb-4">{title}</h3>

      {}
      <div className="flex flex-col gap-3">
        {items.map((item, index) => <a key={index} href={item.href} className="py-2 px-3 rounded related_link hover:bg-[#f2f2f2] dark:hover:bg-[#111827] flex items-center gap-3 group no-underline hover:no-underline border-b-0">
            {}
            <span className={`px-2 py-1 rounded-full text-xs font-semibold uppercase tracking-wide flex-shrink-0 ${getBadgeClass(item.badge)}`}>
              {item.badge}
            </span>

            {}
            <span className="text-base">{item.label}</span>
          </a>)}
      </div>
    </div>;
};

export const Link = ({href, children, className, ...props}) => {
  const localizedHref = localizeLink(href);
  return <a href={localizedHref} className={className} {...props}>
      {children}
    </a>;
};

When someone uploads a product image, logo, or campaign file to a Box intake folder, this service extracts brand metadata and writes those values onto the file. You can advance the file through the entire lifecycle, from Draft to Retired. External agencies upload into Draft only, while regional teams find approved files in a Box Hub.

<Link href="https://blog.box.com/what-is-digital-asset-management">Digital asset management</Link> helps you store, find, share, and retrieve files in one library with access controls. Most of those files are unstructured, so models cannot use them until you attach metadata. See <Link href="https://blog.box.com/get-most-value-your-content-ai-powered-digital-asset-management">AI-powered digital asset management</Link>.

## Before you start

Complete these Box setup steps before you build. You need:

* A <Link href="https://account.box.com/signup/developer#ty9l3">free Box developer account</Link>, or a <Link href="https://www.box.com/pricing">Box Enterprise account</Link> with Box AI enabled. Box Hubs is required only for the publish step. <Link href="/guides/archives">Box Archive</Link> is optional and requires Enterprise Advanced.
* A Box application configured with **Client Credentials Grant** authentication, authorized in the Admin Console, with these scopes:
  * Read and write all files and folders stored in Box
  * Manage AI
  * Manage webhooks
* Python 3.11+ for the Build by hand path. For the agent path, a coding agent such as Codex, Claude Code, or Cursor, and a runtime for the stack you choose. Installing <Link href="/ai/agent-skills">Box Agent Skills</Link> helps the agent use current Box APIs.

Copy the metadata template key, the six lifecycle folder IDs, and the enterprise ID. The agent prompt and your `.env` file use them.

<AccordionGroup>
  <Accordion title="1. Create the brand asset metadata template">
    The metadata template lists the fields Box AI extracts and the lifecycle status your service writes. Create it once. Every processed file then returns values in this shape.

    <Tip>
      This step requires Admin access. If you do not have access, contact your Box administrator.
    </Tip>

    1. Open the [Box Admin Console](https://app.box.com/master) and select **Metadata**.
    2. Select **New** and name it `Brand Asset`.
    3. Add the following fields:

    | Field name | Type | Description |
    | - | - | - |
    | Asset Type | Dropdown | Add options, for example `Product imagery`, `Logo`, `Marketing material` |
    | Campaign | Text | Campaign or collection name visible on or associated with the asset |
    | Product Name | Text | Product, SKU, or offering the asset represents |
    | Brand | Text | Brand or sub-brand |
    | Lifecycle Status | Dropdown | Add options, for example `Draft`, `Creative review`, `Legal review`, `Approved`, `Published`, `Retired` |
    | Usage Rights | Dropdown | Add options, for example `Internal only`, `External`, `Restricted` |
    | Description | Text | Short visual summary of the asset |
    | Capture Date | Date | Camera capture date from EXIF, if present |
    | Camera Make | Text | Camera manufacturer from EXIF, if present |
    | Camera Model | Text | Camera model from EXIF, if present |

    4. Select **Save**, then reopen the template and copy the template key from under the **Template Name**. You need it for the prompt and for `.env`.

    Box derives each field's key from its display name in camel case. `Asset Type` becomes `assetType`. If you rename a field, confirm its key in the Admin Console.

    For a detailed walkthrough, see <Link href="https://support.box.com/hc/en-us/articles/360044194033-Customizing-Metadata-Templates">Customizing Metadata Templates</Link>.

    <Note>
      Box AI Extract reads what is visible in the file: layout, on-image text, and OCR on JPEG, PNG, TIFF, and WEBP. It does not read camera EXIF. Box AI can still fill `captureDate` from visible content, so that value is not necessarily the camera capture date. The optional local EXIF step replaces it when the file has EXIF data.
    </Note>
  </Accordion>

  <Accordion title="2. Create the brand lifecycle folders">
    Create this folder tree in Box. Each lifecycle stage is its own folder so you can grant access per stage. Vendors see Draft. Reviewers see their stage. The Hub indexes Published only.

    1. In Box, create a folder called `Brand DAM` with this structure:

    ```text theme={null}
    Brand DAM/
    ├── Draft/              # vendor drop zone and intake
    ├── Creative Review/
    ├── Legal Review/
    ├── Approved/
    ├── Published/          # Hub indexes this folder
    └── Retired/            # superseded or expired masters
    ```

    2. Note each **folder ID** from the URL. For example, if the URL is `https://app.box.com/folder/123456789`, the folder ID is `123456789`.
    3. **Share every folder in this tree with your application's service account** as **Editor**. A CCG app runs as a service account. That account has no access to your content until you invite it.

    <Warning>
      Without this share, API calls return 404 Not found.

      Find the service account email in the [Developer Console](https://app.box.com/developers/console). Open your app, select the **Configuration** tab, and copy the **Service Account ID** from the **App Details** sidebar. It looks like `AutomationUser_xxxxx_xxxxxx@boxdevedition.com`.

      Invite that email as a collaborator on `Brand DAM` with the **Editor** role so child folders inherit access.
    </Warning>

    <Note>
      The same template and folder pattern can hold owner's manuals, warranty PDFs, or maintenance docs. Add a dropdown value such as `Support document` if you extend the template. This tutorial covers brand assets only.
    </Note>
  </Accordion>
</AccordionGroup>

## Build with an agent

Gather these values from [Before you start](#before-you-start):

* Metadata template key. Copy it from the template you created.
* Six lifecycle folder IDs. Copy them from the folder URLs.
* Enterprise ID. Copy it from the [Developer Console](https://app.box.com/developers/console) using the icon in the top-right.

Choose your stack and copy the prompt. The prompt lists these as already created. The agent reads them from environment variables and does not try to create them. Replace the `<TEMPLATE_KEY>` and `<ENTERPRISE_ID>` placeholders if you want the agent to pre-fill `.env`. Otherwise leave them and fill `.env` yourself after scaffolding.

<div id="dam-agent-prompt-root" className="my-6 w-full rounded-2xl border border-gray-200 bg-white px-6 py-5 dark:border-white/10 dark:bg-transparent">
  <AgentPromptBuilder
    rootId="dam-agent-prompt-root"
    stacks={[
  {
    "language": "Python",
    "frameworks": [
      {
        "name": "Flask"
      },
      {
        "name": "FastAPI"
      }
    ]
  },
  {
    "language": "TypeScript",
    "frameworks": [
      {
        "name": "Express"
      },
      {
        "name": "Fastify"
      }
    ]
  },
  {
    "language": "Node",
    "frameworks": [
      {
        "name": "Express"
      },
      {
        "name": "NestJS"
      }
    ]
  },
  {
    "language": "Java",
    "frameworks": [
      {
        "name": "Spring Boot"
      },
      {
        "name": "Quarkus"
      }
    ]
  },
  {
    "language": ".NET",
    "frameworks": [
      {
        "name": "ASP.NET Core"
      },
      {
        "name": "Minimal APIs"
      }
    ]
  }
]}
  />

  <div className="mt-5">
    <div data-agent-stack="Python||Flask">
      ```md Python + Flask theme={null}
      Build a production-minded AI-powered digital asset management (DAM) service by following this Box tutorial:
      https://developer.box.com/tutorials/ai-powered-dam

      Use Python with Flask. Target Python 3.11+, use pip and a virtual environment. Install exactly one Box package: "boxsdk~=10.0" (do NOT also install box-sdk-gen — both ship the box_sdk_gen module and conflict). Import Box APIs with `from box_sdk_gen import ...`. Also install flask, python-dotenv, and pytest for tests. Optional EXIF: pillow.

      Prerequisites already done in Box (see 'Before you start' in the tutorial).
      Do NOT try to create these; read their values from environment variables:
      - Enterprise metadata template key: <TEMPLATE_KEY>
        (fields assetType, campaign, productName, brand, lifecycleStatus,
        usageRights, description; optional captureDate, cameraMake, cameraModel)
      - Lifecycle folder IDs, each shared with the app service account as Editor:
        DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID, LEGAL_REVIEW_FOLDER_ID,
        APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID, RETIRED_FOLDER_ID
      - Enterprise ID: <ENTERPRISE_ID>
      - CCG app scopes enabled: Read and write all files and folders stored in Box,
        Manage AI, and Manage webhooks

      Mirror the Python samples in the tutorial for Box client, extract, metadata, lifecycle, and vendor invite. Use Flask for the HTTP server as in the Build by hand path, except that POST /webhook responds before processing (see step 2 below).

      The app must:
      1. Authenticate to Box with Client Credentials Grant (enterprise / service account).
         Use BOX_ENTERPRISE_ID — do NOT authenticate as a managed user.
      2. Receive Box FILE.UPLOADED webhook events at POST /webhook. Box expects a 2xx
         response within 30 seconds and retries otherwise, and AI extract can take longer.
         Return 200 right away and run extract and the metadata write in the background
         (an in-process background task is fine here; note in the README that production
         should use a durable queue). Log the extracted fields so the local curl test
         shows them in the server output.
      3. Ignore events for files that are not images Box AI Extract supports
         (JPEG, JPG, PNG, TIFF, TIF, WEBP). Return 200 with a skipped/ignored status.
      4. Call POST /2.0/ai/extract_structured using the brand-asset metadata template
         (template_key + scope enterprise), not inline fields.
      5. Extract assetType, campaign, productName, brand, usageRights, and description.
         Set lifecycleStatus to Draft on intake. Missing fields are OK.
      6. Write the extracted values back to the file as Box enterprise metadata:
         - Try create first.
         - On 409 Conflict (instance already exists), JSON-Patch update with op 'add'
           for each returned field (not 'replace'). Do not fail retests of the same file.
      7. Expose POST /lifecycle that accepts {"file_id", "lifecycle_status"} and:
         - Updates lifecycleStatus on the metadata instance (409-safe upsert).
         - Moves the file into the folder that matches that status.
         Allowed statuses: Draft, Creative review, Legal review, Approved, Published, Retired.
      8. Include invite_vendor.py (runnable as a CLI script) that collaborates an external email
         onto DRAFT_FOLDER_ID as Viewer Uploader (role "viewer uploader"), with notify=true
         and an expires_at date. Treat a 409 with error code user_already_collaborator as
         already invited; do not match on the error message text. In the README, note that
         expires_at requires the enterprise setting 'Automatically remove invited
         collaborators: Allow folder owners to extend the expiry date', that the enterprise
         setting can override the date sent, and to omit expires_at if the setting is off.
      9. Return clear JSON responses and handle Box API errors safely.
      10. Include a local test path that does not require a public webhook URL
          (curl POST to localhost:5000/webhook with a simulated FILE.UPLOADED payload).

      Read configuration from environment variables:
      BOX_CLIENT_ID, BOX_CLIENT_SECRET, BOX_ENTERPRISE_ID,
      BOX_METADATA_TEMPLATE_KEY, DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID,
      LEGAL_REVIEW_FOLDER_ID, APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID,
      RETIRED_FOLDER_ID.
      DRAFT_FOLDER_ID is the intake folder for the production webhook.
      Do not require folder IDs inside POST /webhook except as documented.

      Create a complete runnable project with:
      - box_client.py, extract.py, metadata.py, lifecycle.py, invite_vendor.py, and app.py (POST /webhook and POST /lifecycle) matching the tutorial modules
      - a flat Flask layout matching the tutorial (no unnecessary packages)
      - dependency and run commands
      - .env.example and .gitignore files
      - brief webhook signature verification guidance (link or comment is enough;
        do not block the local curl test on signature checks)
      - idempotency: the 409 metadata upsert above is required
      - focused unit tests and a README with setup and test steps

      Do not invent a Box EXIF API. Camera EXIF (captureDate, cameraMake, cameraModel)
      is optional: download the file and parse it locally (for example Pillow).
      Read captureDate from DateTimeOriginal in the Exif sub-IFD (0x8769) in preference to
      DateTime in the main block, which is the last-modified time. Skip EXIF when the file
      has none. Box AI Extract reads visual/OCR content only, but it can still fill
      captureDate from visible content; EXIF values overwrite it when present.

      Never hard-code credentials. Add concise comments only where the Box-specific behavior is not obvious. Before writing code, summarize the architecture and list the files you will create.
      ```
    </div>

    <div data-agent-stack="Python||FastAPI" hidden>
      ```md Python + FastAPI theme={null}
      Build a production-minded AI-powered digital asset management (DAM) service by following this Box tutorial:
      https://developer.box.com/tutorials/ai-powered-dam

      Use Python with FastAPI. Target Python 3.11+, use pip and a virtual environment. Install exactly one Box package: "boxsdk~=10.0" (do NOT also install box-sdk-gen — both ship the box_sdk_gen module and conflict). Import Box APIs with `from box_sdk_gen import ...`. Also install fastapi, uvicorn, python-dotenv, pytest, and httpx (required by FastAPI's TestClient). Optional EXIF: pillow.

      Prerequisites already done in Box (see 'Before you start' in the tutorial).
      Do NOT try to create these; read their values from environment variables:
      - Enterprise metadata template key: <TEMPLATE_KEY>
        (fields assetType, campaign, productName, brand, lifecycleStatus,
        usageRights, description; optional captureDate, cameraMake, cameraModel)
      - Lifecycle folder IDs, each shared with the app service account as Editor:
        DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID, LEGAL_REVIEW_FOLDER_ID,
        APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID, RETIRED_FOLDER_ID
      - Enterprise ID: <ENTERPRISE_ID>
      - CCG app scopes enabled: Read and write all files and folders stored in Box,
        Manage AI, and Manage webhooks

      The tutorial's Python samples use Flask. Reuse the Box client, extract, metadata, lifecycle, and invite logic from those samples inside the app/ package, and change their imports to package-relative imports (for example from .box_client import get_box_client). Adapt only the HTTP layer to FastAPI + uvicorn. The Box Python SDK is synchronous — use plain def route handlers (or run sync Box calls in a threadpool); do not await sync SDK methods.

      The app must:
      1. Authenticate to Box with Client Credentials Grant (enterprise / service account).
         Use BOX_ENTERPRISE_ID — do NOT authenticate as a managed user.
      2. Receive Box FILE.UPLOADED webhook events at POST /webhook. Box expects a 2xx
         response within 30 seconds and retries otherwise, and AI extract can take longer.
         Return 200 right away and run extract and the metadata write in the background
         (an in-process background task is fine here; note in the README that production
         should use a durable queue). Log the extracted fields so the local curl test
         shows them in the server output.
      3. Ignore events for files that are not images Box AI Extract supports
         (JPEG, JPG, PNG, TIFF, TIF, WEBP). Return 200 with a skipped/ignored status.
      4. Call POST /2.0/ai/extract_structured using the brand-asset metadata template
         (template_key + scope enterprise), not inline fields.
      5. Extract assetType, campaign, productName, brand, usageRights, and description.
         Set lifecycleStatus to Draft on intake. Missing fields are OK.
      6. Write the extracted values back to the file as Box enterprise metadata:
         - Try create first.
         - On 409 Conflict (instance already exists), JSON-Patch update with op 'add'
           for each returned field (not 'replace'). Do not fail retests of the same file.
      7. Expose POST /lifecycle that accepts {"file_id", "lifecycle_status"} and:
         - Updates lifecycleStatus on the metadata instance (409-safe upsert).
         - Moves the file into the folder that matches that status.
         Allowed statuses: Draft, Creative review, Legal review, Approved, Published, Retired.
      8. Include invite_vendor.py inside the app/ package (runnable with python -m app.invite_vendor) that collaborates an external email
         onto DRAFT_FOLDER_ID as Viewer Uploader (role "viewer uploader"), with notify=true
         and an expires_at date. Treat a 409 with error code user_already_collaborator as
         already invited; do not match on the error message text. In the README, note that
         expires_at requires the enterprise setting 'Automatically remove invited
         collaborators: Allow folder owners to extend the expiry date', that the enterprise
         setting can override the date sent, and to omit expires_at if the setting is off.
      9. Return clear JSON responses and handle Box API errors safely.
      10. Include a local test path that does not require a public webhook URL
          (curl POST to localhost:5000/webhook with a simulated FILE.UPLOADED payload).

      Read configuration from environment variables:
      BOX_CLIENT_ID, BOX_CLIENT_SECRET, BOX_ENTERPRISE_ID,
      BOX_METADATA_TEMPLATE_KEY, DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID,
      LEGAL_REVIEW_FOLDER_ID, APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID,
      RETIRED_FOLDER_ID.
      DRAFT_FOLDER_ID is the intake folder for the production webhook.
      Do not require folder IDs inside POST /webhook except as documented.

      Create a complete runnable project with:
      - box_client.py, extract.py, metadata.py, lifecycle.py, invite_vendor.py (same Box logic as the tutorial), and a FastAPI app module with POST /webhook and POST /lifecycle
      - an idiomatic FastAPI layout with an app/ package and the FastAPI app object in app/main.py (the tutorial runs uvicorn app.main:app); keep Box modules separate from the HTTP layer
      - dependency and run commands
      - .env.example and .gitignore files
      - brief webhook signature verification guidance (link or comment is enough;
        do not block the local curl test on signature checks)
      - idempotency: the 409 metadata upsert above is required
      - focused unit tests and a README with setup and test steps

      Do not invent a Box EXIF API. Camera EXIF (captureDate, cameraMake, cameraModel)
      is optional: download the file and parse it locally (for example Pillow).
      Read captureDate from DateTimeOriginal in the Exif sub-IFD (0x8769) in preference to
      DateTime in the main block, which is the last-modified time. Skip EXIF when the file
      has none. Box AI Extract reads visual/OCR content only, but it can still fill
      captureDate from visible content; EXIF values overwrite it when present.

      Never hard-code credentials. Add concise comments only where the Box-specific behavior is not obvious. Before writing code, summarize the architecture and list the files you will create.
      ```
    </div>

    <div data-agent-stack="TypeScript||Express" hidden>
      ```md TypeScript + Express theme={null}
      Build a production-minded AI-powered digital asset management (DAM) service by following this Box tutorial:
      https://developer.box.com/tutorials/ai-powered-dam

      Use TypeScript with Express on Node.js 20+. Use npm, box-node-sdk@10 (BoxClient, BoxCcgAuth, CcgConfig, BoxApiError from box-node-sdk; AiItemBase from box-node-sdk/schemas/aiItemBase), express, dotenv, tsx (or tsc), and a test runner such as node:test or vitest.

      Prerequisites already done in Box (see 'Before you start' in the tutorial).
      Do NOT try to create these; read their values from environment variables:
      - Enterprise metadata template key: <TEMPLATE_KEY>
        (fields assetType, campaign, productName, brand, lifecycleStatus,
        usageRights, description; optional captureDate, cameraMake, cameraModel)
      - Lifecycle folder IDs, each shared with the app service account as Editor:
        DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID, LEGAL_REVIEW_FOLDER_ID,
        APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID, RETIRED_FOLDER_ID
      - Enterprise ID: <ENTERPRISE_ID>
      - CCG app scopes enabled: Read and write all files and folders stored in Box,
        Manage AI, and Manage webhooks

      The tutorial's Build by hand samples are Python only. Use those as the behavioral spec for CCG enterprise auth, extract_structured with the metadata template (not inline fields), 409 metadata upsert with JSON-Patch op add, lifecycle file moves, vendor collaborations, and webhook handling. Adapt the HTTP layer to Express in TypeScript. In tsconfig, set module and moduleResolution to node16 (plus esModuleInterop and strict); moduleResolution node cannot resolve box-node-sdk subpath exports such as box-node-sdk/schemas/aiItemBase, so tsc and type-aware test runners fail. Run with npx tsx app.ts. Keep listen on port 5000 for the local curl test.

      The app must:
      1. Authenticate to Box with Client Credentials Grant (enterprise / service account).
         Use BOX_ENTERPRISE_ID — do NOT authenticate as a managed user.
      2. Receive Box FILE.UPLOADED webhook events at POST /webhook. Box expects a 2xx
         response within 30 seconds and retries otherwise, and AI extract can take longer.
         Return 200 right away and run extract and the metadata write in the background
         (an in-process background task is fine here; note in the README that production
         should use a durable queue). Log the extracted fields so the local curl test
         shows them in the server output.
      3. Ignore events for files that are not images Box AI Extract supports
         (JPEG, JPG, PNG, TIFF, TIF, WEBP). Return 200 with a skipped/ignored status.
      4. Call POST /2.0/ai/extract_structured using the brand-asset metadata template
         (template_key + scope enterprise), not inline fields.
      5. Extract assetType, campaign, productName, brand, usageRights, and description.
         Set lifecycleStatus to Draft on intake. Missing fields are OK.
      6. Write the extracted values back to the file as Box enterprise metadata:
         - Try create first.
         - On 409 Conflict (instance already exists), JSON-Patch update with op 'add'
           for each returned field (not 'replace'). Do not fail retests of the same file.
      7. Expose POST /lifecycle that accepts {"file_id", "lifecycle_status"} and:
         - Updates lifecycleStatus on the metadata instance (409-safe upsert).
         - Moves the file into the folder that matches that status.
         Allowed statuses: Draft, Creative review, Legal review, Approved, Published, Retired.
      8. Include inviteVendor.ts (runnable from the CLI with npx tsx inviteVendor.ts <email>) that collaborates an external email
         onto DRAFT_FOLDER_ID as Viewer Uploader (role "viewer uploader"), with notify=true
         and an expires_at date. Treat a 409 with error code user_already_collaborator as
         already invited; do not match on the error message text. In the README, note that
         expires_at requires the enterprise setting 'Automatically remove invited
         collaborators: Allow folder owners to extend the expiry date', that the enterprise
         setting can override the date sent, and to omit expires_at if the setting is off.
      9. Return clear JSON responses and handle Box API errors safely.
      10. Include a local test path that does not require a public webhook URL
          (curl POST to localhost:5000/webhook with a simulated FILE.UPLOADED payload).

      Read configuration from environment variables:
      BOX_CLIENT_ID, BOX_CLIENT_SECRET, BOX_ENTERPRISE_ID,
      BOX_METADATA_TEMPLATE_KEY, DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID,
      LEGAL_REVIEW_FOLDER_ID, APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID,
      RETIRED_FOLDER_ID.
      DRAFT_FOLDER_ID is the intake folder for the production webhook.
      Do not require folder IDs inside POST /webhook except as documented.

      Create a complete runnable project with:
      - boxClient.ts, extract.ts, metadata.ts, lifecycle.ts, inviteVendor.ts, and app.ts (POST /webhook and POST /lifecycle)
      - a flat Express + TypeScript layout
      - dependency and run commands
      - .env.example and .gitignore files
      - brief webhook signature verification guidance (link or comment is enough;
        do not block the local curl test on signature checks)
      - idempotency: the 409 metadata upsert above is required
      - focused unit tests and a README with setup and test steps

      Do not invent a Box EXIF API. Camera EXIF (captureDate, cameraMake, cameraModel)
      is optional: download the file and parse it locally (for example exifr).
      Read captureDate from DateTimeOriginal in the Exif sub-IFD (0x8769) in preference to
      DateTime in the main block, which is the last-modified time. Skip EXIF when the file
      has none. Box AI Extract reads visual/OCR content only, but it can still fill
      captureDate from visible content; EXIF values overwrite it when present.

      Never hard-code credentials. Add concise comments only where the Box-specific behavior is not obvious. Before writing code, summarize the architecture and list the files you will create.
      ```
    </div>

    <div data-agent-stack="TypeScript||Fastify" hidden>
      ```md TypeScript + Fastify theme={null}
      Build a production-minded AI-powered digital asset management (DAM) service by following this Box tutorial:
      https://developer.box.com/tutorials/ai-powered-dam

      Use TypeScript with Fastify on Node.js 20+. Use npm, box-node-sdk@10 (BoxClient, BoxCcgAuth, CcgConfig, BoxApiError from box-node-sdk; AiItemBase from box-node-sdk/schemas/aiItemBase), fastify, dotenv, tsx (or tsc), and a test runner such as node:test or vitest.

      Prerequisites already done in Box (see 'Before you start' in the tutorial).
      Do NOT try to create these; read their values from environment variables:
      - Enterprise metadata template key: <TEMPLATE_KEY>
        (fields assetType, campaign, productName, brand, lifecycleStatus,
        usageRights, description; optional captureDate, cameraMake, cameraModel)
      - Lifecycle folder IDs, each shared with the app service account as Editor:
        DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID, LEGAL_REVIEW_FOLDER_ID,
        APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID, RETIRED_FOLDER_ID
      - Enterprise ID: <ENTERPRISE_ID>
      - CCG app scopes enabled: Read and write all files and folders stored in Box,
        Manage AI, and Manage webhooks

      The tutorial's Build by hand samples are Python only. Use those as the behavioral spec. Adapt only the HTTP layer to Fastify. Fastify parses JSON request bodies by default; do not register another application/json content type parser (it throws FST_ERR_CTP_ALREADY_PRESENT). In tsconfig, set module and moduleResolution to node16; moduleResolution node cannot resolve box-node-sdk subpath exports such as box-node-sdk/schemas/aiItemBase. Add an npm start script that starts the server (the tutorial runs npm start). Keep listen on port 5000 for the local curl test.

      The app must:
      1. Authenticate to Box with Client Credentials Grant (enterprise / service account).
         Use BOX_ENTERPRISE_ID — do NOT authenticate as a managed user.
      2. Receive Box FILE.UPLOADED webhook events at POST /webhook. Box expects a 2xx
         response within 30 seconds and retries otherwise, and AI extract can take longer.
         Return 200 right away and run extract and the metadata write in the background
         (an in-process background task is fine here; note in the README that production
         should use a durable queue). Log the extracted fields so the local curl test
         shows them in the server output.
      3. Ignore events for files that are not images Box AI Extract supports
         (JPEG, JPG, PNG, TIFF, TIF, WEBP). Return 200 with a skipped/ignored status.
      4. Call POST /2.0/ai/extract_structured using the brand-asset metadata template
         (template_key + scope enterprise), not inline fields.
      5. Extract assetType, campaign, productName, brand, usageRights, and description.
         Set lifecycleStatus to Draft on intake. Missing fields are OK.
      6. Write the extracted values back to the file as Box enterprise metadata:
         - Try create first.
         - On 409 Conflict (instance already exists), JSON-Patch update with op 'add'
           for each returned field (not 'replace'). Do not fail retests of the same file.
      7. Expose POST /lifecycle that accepts {"file_id", "lifecycle_status"} and:
         - Updates lifecycleStatus on the metadata instance (409-safe upsert).
         - Moves the file into the folder that matches that status.
         Allowed statuses: Draft, Creative review, Legal review, Approved, Published, Retired.
      8. Include an inviteVendor.ts module with a CLI entry point (for example an npm script) that collaborates an external email
         onto DRAFT_FOLDER_ID as Viewer Uploader (role "viewer uploader"), with notify=true
         and an expires_at date. Treat a 409 with error code user_already_collaborator as
         already invited; do not match on the error message text. In the README, note that
         expires_at requires the enterprise setting 'Automatically remove invited
         collaborators: Allow folder owners to extend the expiry date', that the enterprise
         setting can override the date sent, and to omit expires_at if the setting is off.
      9. Return clear JSON responses and handle Box API errors safely.
      10. Include a local test path that does not require a public webhook URL
          (curl POST to localhost:5000/webhook with a simulated FILE.UPLOADED payload).

      Read configuration from environment variables:
      BOX_CLIENT_ID, BOX_CLIENT_SECRET, BOX_ENTERPRISE_ID,
      BOX_METADATA_TEMPLATE_KEY, DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID,
      LEGAL_REVIEW_FOLDER_ID, APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID,
      RETIRED_FOLDER_ID.
      DRAFT_FOLDER_ID is the intake folder for the production webhook.
      Do not require folder IDs inside POST /webhook except as documented.

      Create a complete runnable project with:
      - boxClient.ts, extract.ts, metadata.ts, lifecycle.ts, inviteVendor.ts, and a Fastify app module with POST /webhook and POST /lifecycle
      - an idiomatic Fastify + TypeScript layout; keep Box modules separate from the HTTP layer
      - dependency and run commands
      - .env.example and .gitignore files
      - brief webhook signature verification guidance (link or comment is enough;
        do not block the local curl test on signature checks)
      - idempotency: the 409 metadata upsert above is required
      - focused unit tests and a README with setup and test steps

      Do not invent a Box EXIF API. Camera EXIF (captureDate, cameraMake, cameraModel)
      is optional: download the file and parse it locally (for example exifr).
      Read captureDate from DateTimeOriginal in the Exif sub-IFD (0x8769) in preference to
      DateTime in the main block, which is the last-modified time. Skip EXIF when the file
      has none. Box AI Extract reads visual/OCR content only, but it can still fill
      captureDate from visible content; EXIF values overwrite it when present.

      Never hard-code credentials. Add concise comments only where the Box-specific behavior is not obvious. Before writing code, summarize the architecture and list the files you will create.
      ```
    </div>

    <div data-agent-stack="Node||Express" hidden>
      ```md Node + Express theme={null}
      Build a production-minded AI-powered digital asset management (DAM) service by following this Box tutorial:
      https://developer.box.com/tutorials/ai-powered-dam

      Use Node.js 20+ with JavaScript (not TypeScript) and Express. Use npm and exactly one Box package: box-node-sdk@10 (do NOT also install box-typescript-sdk-gen). Import BoxClient, BoxCcgAuth, CcgConfig, and BoxApiError from box-node-sdk, and AiItemBase from box-node-sdk/schemas/aiItemBase. Also install express, dotenv, and a test runner such as node:test.

      Prerequisites already done in Box (see 'Before you start' in the tutorial).
      Do NOT try to create these; read their values from environment variables:
      - Enterprise metadata template key: <TEMPLATE_KEY>
        (fields assetType, campaign, productName, brand, lifecycleStatus,
        usageRights, description; optional captureDate, cameraMake, cameraModel)
      - Lifecycle folder IDs, each shared with the app service account as Editor:
        DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID, LEGAL_REVIEW_FOLDER_ID,
        APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID, RETIRED_FOLDER_ID
      - Enterprise ID: <ENTERPRISE_ID>
      - CCG app scopes enabled: Read and write all files and folders stored in Box,
        Manage AI, and Manage webhooks

      The tutorial's Build by hand samples are Python only. Use those as the behavioral spec for Box client, extract, metadata, 409 metadata upsert, lifecycle moves, vendor collaborations, and webhook handling. Adapt the HTTP layer to Express in JavaScript. Keep listen on port 5000 for the local curl test.

      The app must:
      1. Authenticate to Box with Client Credentials Grant (enterprise / service account).
         Use BOX_ENTERPRISE_ID — do NOT authenticate as a managed user.
      2. Receive Box FILE.UPLOADED webhook events at POST /webhook. Box expects a 2xx
         response within 30 seconds and retries otherwise, and AI extract can take longer.
         Return 200 right away and run extract and the metadata write in the background
         (an in-process background task is fine here; note in the README that production
         should use a durable queue). Log the extracted fields so the local curl test
         shows them in the server output.
      3. Ignore events for files that are not images Box AI Extract supports
         (JPEG, JPG, PNG, TIFF, TIF, WEBP). Return 200 with a skipped/ignored status.
      4. Call POST /2.0/ai/extract_structured using the brand-asset metadata template
         (template_key + scope enterprise), not inline fields.
      5. Extract assetType, campaign, productName, brand, usageRights, and description.
         Set lifecycleStatus to Draft on intake. Missing fields are OK.
      6. Write the extracted values back to the file as Box enterprise metadata:
         - Try create first.
         - On 409 Conflict (instance already exists), JSON-Patch update with op 'add'
           for each returned field (not 'replace'). Do not fail retests of the same file.
      7. Expose POST /lifecycle that accepts {"file_id", "lifecycle_status"} and:
         - Updates lifecycleStatus on the metadata instance (409-safe upsert).
         - Moves the file into the folder that matches that status.
         Allowed statuses: Draft, Creative review, Legal review, Approved, Published, Retired.
      8. Include inviteVendor.js (runnable from the CLI with node inviteVendor.js <email>) that collaborates an external email
         onto DRAFT_FOLDER_ID as Viewer Uploader (role "viewer uploader"), with notify=true
         and an expires_at date. Treat a 409 with error code user_already_collaborator as
         already invited; do not match on the error message text. In the README, note that
         expires_at requires the enterprise setting 'Automatically remove invited
         collaborators: Allow folder owners to extend the expiry date', that the enterprise
         setting can override the date sent, and to omit expires_at if the setting is off.
      9. Return clear JSON responses and handle Box API errors safely.
      10. Include a local test path that does not require a public webhook URL
          (curl POST to localhost:5000/webhook with a simulated FILE.UPLOADED payload).

      Read configuration from environment variables:
      BOX_CLIENT_ID, BOX_CLIENT_SECRET, BOX_ENTERPRISE_ID,
      BOX_METADATA_TEMPLATE_KEY, DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID,
      LEGAL_REVIEW_FOLDER_ID, APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID,
      RETIRED_FOLDER_ID.
      DRAFT_FOLDER_ID is the intake folder for the production webhook.
      Do not require folder IDs inside POST /webhook except as documented.

      Create a complete runnable project with:
      - boxClient.js, extract.js, metadata.js, lifecycle.js, inviteVendor.js, and app.js (POST /webhook and POST /lifecycle)
      - a flat Express + JavaScript layout; keep Box modules separate from the HTTP layer
      - dependency and run commands
      - .env.example and .gitignore files
      - brief webhook signature verification guidance (link or comment is enough;
        do not block the local curl test on signature checks)
      - idempotency: the 409 metadata upsert above is required
      - focused unit tests and a README with setup and test steps

      Do not invent a Box EXIF API. Camera EXIF (captureDate, cameraMake, cameraModel)
      is optional: download the file and parse it locally (for example exifr).
      Read captureDate from DateTimeOriginal in the Exif sub-IFD (0x8769) in preference to
      DateTime in the main block, which is the last-modified time. Skip EXIF when the file
      has none. Box AI Extract reads visual/OCR content only, but it can still fill
      captureDate from visible content; EXIF values overwrite it when present.

      Never hard-code credentials. Add concise comments only where the Box-specific behavior is not obvious. Before writing code, summarize the architecture and list the files you will create.
      ```
    </div>

    <div data-agent-stack="Node||NestJS" hidden>
      ```md Node + NestJS theme={null}
      Build a production-minded AI-powered digital asset management (DAM) service by following this Box tutorial:
      https://developer.box.com/tutorials/ai-powered-dam

      Use Node.js 20+ with NestJS. NestJS projects use TypeScript by default. Use npm and exactly one Box package: box-node-sdk@10 (do NOT also install box-typescript-sdk-gen). Import BoxClient, BoxCcgAuth, CcgConfig, and BoxApiError from box-node-sdk, and AiItemBase from box-node-sdk/schemas/aiItemBase. Also install @nestjs/core@^11, @nestjs/common@^11, @nestjs/platform-express@^11, dotenv, and a test runner such as Jest. Pin NestJS to 11: NestJS 12 is ESM-only, which breaks a CommonJS build and Jest.

      Prerequisites already done in Box (see 'Before you start' in the tutorial).
      Do NOT try to create these; read their values from environment variables:
      - Enterprise metadata template key: <TEMPLATE_KEY>
        (fields assetType, campaign, productName, brand, lifecycleStatus,
        usageRights, description; optional captureDate, cameraMake, cameraModel)
      - Lifecycle folder IDs, each shared with the app service account as Editor:
        DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID, LEGAL_REVIEW_FOLDER_ID,
        APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID, RETIRED_FOLDER_ID
      - Enterprise ID: <ENTERPRISE_ID>
      - CCG app scopes enabled: Read and write all files and folders stored in Box,
        Manage AI, and Manage webhooks

      The tutorial's Build by hand samples are Python only. Use those as the behavioral spec. Adapt only the HTTP layer to NestJS. Enable JSON body parsing. NestJS POST handlers return 201 by default — set @HttpCode(200) on POST /webhook and POST /lifecycle so the local curl tests see 200. For the AiItemBase import from box-node-sdk/schemas/aiItemBase, TypeScript moduleResolution node cannot resolve the SDK package export; set module and moduleResolution to node16 in tsconfig so nest build and Jest succeed. Add an npm start script that starts the server (the tutorial runs npm start). Keep listen on port 5000 for the local curl test.

      The app must:
      1. Authenticate to Box with Client Credentials Grant (enterprise / service account).
         Use BOX_ENTERPRISE_ID — do NOT authenticate as a managed user.
      2. Receive Box FILE.UPLOADED webhook events at POST /webhook. Box expects a 2xx
         response within 30 seconds and retries otherwise, and AI extract can take longer.
         Return 200 right away and run extract and the metadata write in the background
         (an in-process background task is fine here; note in the README that production
         should use a durable queue). Log the extracted fields so the local curl test
         shows them in the server output.
      3. Ignore events for files that are not images Box AI Extract supports
         (JPEG, JPG, PNG, TIFF, TIF, WEBP). Return 200 with a skipped/ignored status.
      4. Call POST /2.0/ai/extract_structured using the brand-asset metadata template
         (template_key + scope enterprise), not inline fields.
      5. Extract assetType, campaign, productName, brand, usageRights, and description.
         Set lifecycleStatus to Draft on intake. Missing fields are OK.
      6. Write the extracted values back to the file as Box enterprise metadata:
         - Try create first.
         - On 409 Conflict (instance already exists), JSON-Patch update with op 'add'
           for each returned field (not 'replace'). Do not fail retests of the same file.
      7. Expose POST /lifecycle that accepts {"file_id", "lifecycle_status"} and:
         - Updates lifecycleStatus on the metadata instance (409-safe upsert).
         - Moves the file into the folder that matches that status.
         Allowed statuses: Draft, Creative review, Legal review, Approved, Published, Retired.
      8. Include a vendor-invite provider with a CLI entry point (for example an npm script) that collaborates an external email
         onto DRAFT_FOLDER_ID as Viewer Uploader (role "viewer uploader"), with notify=true
         and an expires_at date. Treat a 409 with error code user_already_collaborator as
         already invited; do not match on the error message text. In the README, note that
         expires_at requires the enterprise setting 'Automatically remove invited
         collaborators: Allow folder owners to extend the expiry date', that the enterprise
         setting can override the date sent, and to omit expires_at if the setting is off.
      9. Return clear JSON responses and handle Box API errors safely.
      10. Include a local test path that does not require a public webhook URL
          (curl POST to localhost:5000/webhook with a simulated FILE.UPLOADED payload).

      Read configuration from environment variables:
      BOX_CLIENT_ID, BOX_CLIENT_SECRET, BOX_ENTERPRISE_ID,
      BOX_METADATA_TEMPLATE_KEY, DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID,
      LEGAL_REVIEW_FOLDER_ID, APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID,
      RETIRED_FOLDER_ID.
      DRAFT_FOLDER_ID is the intake folder for the production webhook.
      Do not require folder IDs inside POST /webhook except as documented.

      Create a complete runnable project with:
      - a Box client provider, extract, metadata, lifecycle, and vendor-invite providers, and a controller with POST /webhook and POST /lifecycle
      - an idiomatic NestJS layout (module, controller, providers); keep Box client, extract, metadata, lifecycle, and vendor invite in dedicated providers separate from the HTTP controller
      - dependency and run commands
      - .env.example and .gitignore files
      - brief webhook signature verification guidance (link or comment is enough;
        do not block the local curl test on signature checks)
      - idempotency: the 409 metadata upsert above is required
      - focused unit tests and a README with setup and test steps

      Do not invent a Box EXIF API. Camera EXIF (captureDate, cameraMake, cameraModel)
      is optional: download the file and parse it locally (for example exifr).
      Read captureDate from DateTimeOriginal in the Exif sub-IFD (0x8769) in preference to
      DateTime in the main block, which is the last-modified time. Skip EXIF when the file
      has none. Box AI Extract reads visual/OCR content only, but it can still fill
      captureDate from visible content; EXIF values overwrite it when present.

      Never hard-code credentials. Add concise comments only where the Box-specific behavior is not obvious. Before writing code, summarize the architecture and list the files you will create.
      ```
    </div>

    <div data-agent-stack="Java||Spring Boot" hidden>
      ```md Java + Spring Boot theme={null}
      Build a production-minded AI-powered digital asset management (DAM) service by following this Box tutorial:
      https://developer.box.com/tutorials/ai-powered-dam

      Use Java 17+ with Spring Boot 3. Use Maven or Gradle and exactly one Box artifact: com.box:box-java-sdk version 10 or later (do NOT also add box-java-sdk-gen). Use com.box.sdkgen.client.BoxClient with BoxCCGAuth, CCGConfig, BoxAPIError, and the generated AI/metadata/collaboration types (packages under com.box.sdkgen). Also use spring-boot-starter-web, dotenv-java (load .env from the project root — Spring Boot does not load .env files by itself), and spring-boot-starter-test. Optional EXIF: metadata-extractor.

      Prerequisites already done in Box (see 'Before you start' in the tutorial).
      Do NOT try to create these; read their values from environment variables:
      - Enterprise metadata template key: <TEMPLATE_KEY>
        (fields assetType, campaign, productName, brand, lifecycleStatus,
        usageRights, description; optional captureDate, cameraMake, cameraModel)
      - Lifecycle folder IDs, each shared with the app service account as Editor:
        DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID, LEGAL_REVIEW_FOLDER_ID,
        APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID, RETIRED_FOLDER_ID
      - Enterprise ID: <ENTERPRISE_ID>
      - CCG app scopes enabled: Read and write all files and folders stored in Box,
        Manage AI, and Manage webhooks

      The tutorial's Build by hand samples are Python only. Use those as the behavioral spec for CCG enterprise auth, extract_structured with the metadata template (not inline fields), 409 metadata upsert with JSON-Patch op add, file moves, vendor collaborations, and webhook handling. Adapt the HTTP layer to Spring Web (@RestController). Call client.getAi().createAiExtractStructured(...) with an AiExtractStructured.Builder request whose metadataTemplate is an AiExtractStructuredMetadataTemplateField (templateKey + type METADATA_TEMPLATE + scope "enterprise"). On 409, catch BoxAPIError and check getResponseInfo().getStatusCode(). For JSON-Patch updates, UpdateFileMetadataByIdRequestBody.Builder.value(...) accepts only String, long, double, List<String>, or MetadataInstanceValue — convert extracted Object values to those overloads; do not pass Object. Keep the server on port 5000 (server.port=5000). Include a @SpringBootTest context-load test so startup wiring errors (for example a bean with two constructors and no @Autowired) fail the test run, not only mvn spring-boot:run. README run steps: from the project directory, mvn spring-boot:run (after .env is present); on macOS with Homebrew JDK, note export JAVA_HOME/PATH if java is not found.

      The app must:
      1. Authenticate to Box with Client Credentials Grant (enterprise / service account).
         Use BOX_ENTERPRISE_ID — do NOT authenticate as a managed user.
      2. Receive Box FILE.UPLOADED webhook events at POST /webhook. Box expects a 2xx
         response within 30 seconds and retries otherwise, and AI extract can take longer.
         Return 200 right away and run extract and the metadata write in the background
         (an in-process background task is fine here; note in the README that production
         should use a durable queue). Log the extracted fields so the local curl test
         shows them in the server output.
      3. Ignore events for files that are not images Box AI Extract supports
         (JPEG, JPG, PNG, TIFF, TIF, WEBP). Return 200 with a skipped/ignored status.
      4. Call POST /2.0/ai/extract_structured using the brand-asset metadata template
         (template_key + scope enterprise), not inline fields.
      5. Extract assetType, campaign, productName, brand, usageRights, and description.
         Set lifecycleStatus to Draft on intake. Missing fields are OK.
      6. Write the extracted values back to the file as Box enterprise metadata:
         - Try create first.
         - On 409 Conflict (instance already exists), JSON-Patch update with op 'add'
           for each returned field (not 'replace'). Do not fail retests of the same file.
      7. Expose POST /lifecycle that accepts {"file_id", "lifecycle_status"} and:
         - Updates lifecycleStatus on the metadata instance (409-safe upsert).
         - Moves the file into the folder that matches that status.
         Allowed statuses: Draft, Creative review, Legal review, Approved, Published, Retired.
      8. Include a VendorInviteService with a command-line entry point that collaborates an external email
         onto DRAFT_FOLDER_ID as Viewer Uploader (role "viewer uploader"), with notify=true
         and an expires_at date. Treat a 409 with error code user_already_collaborator as
         already invited; do not match on the error message text. In the README, note that
         expires_at requires the enterprise setting 'Automatically remove invited
         collaborators: Allow folder owners to extend the expiry date', that the enterprise
         setting can override the date sent, and to omit expires_at if the setting is off.
      9. Return clear JSON responses and handle Box API errors safely.
      10. Include a local test path that does not require a public webhook URL
          (curl POST to localhost:5000/webhook with a simulated FILE.UPLOADED payload).

      Read configuration from environment variables:
      BOX_CLIENT_ID, BOX_CLIENT_SECRET, BOX_ENTERPRISE_ID,
      BOX_METADATA_TEMPLATE_KEY, DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID,
      LEGAL_REVIEW_FOLDER_ID, APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID,
      RETIRED_FOLDER_ID.
      DRAFT_FOLDER_ID is the intake folder for the production webhook.
      Do not require folder IDs inside POST /webhook except as documented.

      Create a complete runnable project with:
      - a Box client configuration/bean, ExtractService, MetadataService, LifecycleService, VendorInviteService, and a controller with POST /webhook and POST /lifecycle
      - an idiomatic Spring Boot layout; keep Box client configuration, extract, metadata, lifecycle, and vendor invite in dedicated classes separate from the HTTP controller
      - dependency and run commands
      - .env.example and .gitignore files
      - brief webhook signature verification guidance (link or comment is enough;
        do not block the local curl test on signature checks)
      - idempotency: the 409 metadata upsert above is required
      - focused unit tests and a README with setup and test steps

      Local dev and troubleshooting (document in README):
      - Copy .env.example to .env in the project root and fill every variable before the first run.
      - Load .env at startup with dotenv-java (Spring Boot and Quarkus do not load .env files by themselves). Check OS environment variables first so export in the shell still overrides .env.
      - A generic Maven BUILD FAILURE with exit code 1 from spring-boot:run or quarkus:dev usually means startup failed, not compile. Document mvn spring-boot:run -e (or the Quarkus equivalent); typical causes are missing BOX_* or folder IDs.

      Do not invent a Box EXIF API. Camera EXIF (captureDate, cameraMake, cameraModel)
      is optional: download the file and parse it locally (for example metadata-extractor).
      Read captureDate from DateTimeOriginal in the Exif sub-IFD (0x8769) in preference to
      DateTime in the main block, which is the last-modified time. Skip EXIF when the file
      has none. Box AI Extract reads visual/OCR content only, but it can still fill
      captureDate from visible content; EXIF values overwrite it when present.

      Never hard-code credentials. Add concise comments only where the Box-specific behavior is not obvious. Before writing code, summarize the architecture and list the files you will create.
      ```
    </div>

    <div data-agent-stack="Java||Quarkus" hidden>
      ```md Java + Quarkus theme={null}
      Build a production-minded AI-powered digital asset management (DAM) service by following this Box tutorial:
      https://developer.box.com/tutorials/ai-powered-dam

      Use Java 17+ with Quarkus. Use Maven or Gradle and exactly one Box artifact: com.box:box-java-sdk version 10 or later (do NOT also add box-java-sdk-gen). Use BoxCCGAuth, CCGConfig, BoxAPIError, and generated types under com.box.sdkgen (managers and schemas). Also use quarkus-rest-jackson, dotenv-java (load .env from the project root), and quarkus-junit5. Optional EXIF: metadata-extractor.

      Prerequisites already done in Box (see 'Before you start' in the tutorial).
      Do NOT try to create these; read their values from environment variables:
      - Enterprise metadata template key: <TEMPLATE_KEY>
        (fields assetType, campaign, productName, brand, lifecycleStatus,
        usageRights, description; optional captureDate, cameraMake, cameraModel)
      - Lifecycle folder IDs, each shared with the app service account as Editor:
        DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID, LEGAL_REVIEW_FOLDER_ID,
        APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID, RETIRED_FOLDER_ID
      - Enterprise ID: <ENTERPRISE_ID>
      - CCG app scopes enabled: Read and write all files and folders stored in Box,
        Manage AI, and Manage webhooks

      The tutorial's Build by hand samples are Python only; use them as the behavioral spec (same as the Spring Boot agent prompt: CCG enterprise auth, extract_structured with metadata template, 409 metadata upsert with JSON-Patch op add, lifecycle moves, vendor collaborations, webhook handling). Adapt the HTTP layer to Quarkus REST (JAX-RS).

      Box Java (Quarkus): wire Box through one @ApplicationScoped BoxSession bean — not a shared BoxClient from BoxClient.withTimeouts() or BoxClient.Builder alone. In BoxSession: build BoxCCGAuth from CCGConfig (client id/secret + enterpriseId); build NetworkSession with new NetworkSession().withTimeoutConfig(TimeoutConfig with read and request timeout about 300000 ms for AI extract); expose ai(), fileMetadata(), files(), downloads(), and userCollaborations() that each return the matching *Manager from new XxxManager.Builder().auth(auth).networkSession(networkSession).build(). Services call boxSession.ai().createAiExtractStructured(...) with AiExtractStructured.Builder whose metadataTemplate is AiExtractStructuredMetadataTemplateField (templateKey, type METADATA_TEMPLATE, scope "enterprise"). On metadata 409, catch BoxAPIError and check getResponseInfo().getStatusCode(); patch with op add. For JSON-Patch, UpdateFileMetadataByIdRequestBody.Builder.value(...) accepts only String, long, double, List<String>, or MetadataInstanceValue. Annotate POST /webhook and POST /lifecycle with @Blocking. quarkus.http.port=5000.

      The app must:
      1. Authenticate to Box with Client Credentials Grant (enterprise / service account).
         Use BOX_ENTERPRISE_ID — do NOT authenticate as a managed user.
      2. Receive Box FILE.UPLOADED webhook events at POST /webhook. Box expects a 2xx
         response within 30 seconds and retries otherwise, and AI extract can take longer.
         Return 200 right away and run extract and the metadata write in the background
         (an in-process background task is fine here; note in the README that production
         should use a durable queue). Log the extracted fields so the local curl test
         shows them in the server output.
      3. Ignore events for files that are not images Box AI Extract supports
         (JPEG, JPG, PNG, TIFF, TIF, WEBP). Return 200 with a skipped/ignored status.
      4. Call POST /2.0/ai/extract_structured using the brand-asset metadata template
         (template_key + scope enterprise), not inline fields.
      5. Extract assetType, campaign, productName, brand, usageRights, and description.
         Set lifecycleStatus to Draft on intake. Missing fields are OK.
      6. Write the extracted values back to the file as Box enterprise metadata:
         - Try create first.
         - On 409 Conflict (instance already exists), JSON-Patch update with op 'add'
           for each returned field (not 'replace'). Do not fail retests of the same file.
      7. Expose POST /lifecycle that accepts {"file_id", "lifecycle_status"} and:
         - Updates lifecycleStatus on the metadata instance (409-safe upsert).
         - Moves the file into the folder that matches that status.
         Allowed statuses: Draft, Creative review, Legal review, Approved, Published, Retired.
      8. Include a VendorInviteService with a command-line entry point that collaborates an external email
         onto DRAFT_FOLDER_ID as Viewer Uploader (role "viewer uploader"), with notify=true
         and an expires_at date. Treat a 409 with error code user_already_collaborator as
         already invited; do not match on the error message text. In the README, note that
         expires_at requires the enterprise setting 'Automatically remove invited
         collaborators: Allow folder owners to extend the expiry date', that the enterprise
         setting can override the date sent, and to omit expires_at if the setting is off.
      9. Return clear JSON responses and handle Box API errors safely.
      10. Include a local test path that does not require a public webhook URL
          (curl POST to localhost:5000/webhook with a simulated FILE.UPLOADED payload).

      Read configuration from environment variables:
      BOX_CLIENT_ID, BOX_CLIENT_SECRET, BOX_ENTERPRISE_ID,
      BOX_METADATA_TEMPLATE_KEY, DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID,
      LEGAL_REVIEW_FOLDER_ID, APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID,
      RETIRED_FOLDER_ID.
      DRAFT_FOLDER_ID is the intake folder for the production webhook.
      Do not require folder IDs inside POST /webhook except as documented.

      Create a complete runnable project with:
      - a @ApplicationScoped BoxSession bean, ExtractService, MetadataService, LifecycleService, VendorInviteService, and a JAX-RS resource with POST /webhook and POST /lifecycle
      - an idiomatic Quarkus layout; keep BoxSession, extract, metadata, lifecycle, and vendor invite in dedicated beans separate from the JAX-RS resource
      - dependency and run commands
      - .env.example and .gitignore files
      - brief webhook signature verification guidance (link or comment is enough;
        do not block the local curl test on signature checks)
      - idempotency: the 409 metadata upsert above is required
      - focused unit tests and a README with setup and test steps

      Local dev and troubleshooting (document in README):
      - Copy .env.example to .env in the project root and fill every variable before the first run.
      - Load .env at startup with dotenv-java (Spring Boot and Quarkus do not load .env files by themselves). Check OS environment variables first so export in the shell still overrides .env.
      - A generic Maven BUILD FAILURE with exit code 1 from spring-boot:run or quarkus:dev usually means startup failed, not compile. Document mvn spring-boot:run -e (or the Quarkus equivalent); typical causes are missing BOX_* or folder IDs.
      - Run mvn quarkus:dev from the project directory so dotenv-java loads .env.
      - Annotate POST /webhook and POST /lifecycle with @Blocking (the Box Java SDK is synchronous).
      - Set quarkus.http.read-timeout=5M.

      Do not invent a Box EXIF API. Camera EXIF (captureDate, cameraMake, cameraModel)
      is optional: download the file and parse it locally (for example metadata-extractor).
      Read captureDate from DateTimeOriginal in the Exif sub-IFD (0x8769) in preference to
      DateTime in the main block, which is the last-modified time. Skip EXIF when the file
      has none. Box AI Extract reads visual/OCR content only, but it can still fill
      captureDate from visible content; EXIF values overwrite it when present.

      Never hard-code credentials. Add concise comments only where the Box-specific behavior is not obvious. Before writing code, summarize the architecture and list the files you will create.
      ```
    </div>

    <div data-agent-stack=".NET||ASP.NET Core" hidden>
      ```md .NET + ASP.NET Core theme={null}
      Build a production-minded AI-powered digital asset management (DAM) service by following this Box tutorial:
      https://developer.box.com/tutorials/ai-powered-dam

      Use .NET 8+ with ASP.NET Core controllers. Use NuGet and exactly one Box package: Box.V2.Core version 10 or later (do NOT also install Box.Sdk.Gen — v10 of Box.V2.Core already ships the generated client). Use BoxClient, BoxCcgAuth, CcgConfig, and BoxApiException (or the SDK's API error type) from that package. Also add a test SDK such as xUnit. Use the Microsoft.NET.Sdk.Web project SDK, which already includes ASP.NET Core; do not add a Microsoft.AspNetCore.App package reference.

      Prerequisites already done in Box (see 'Before you start' in the tutorial).
      Do NOT try to create these; read their values from environment variables:
      - Enterprise metadata template key: <TEMPLATE_KEY>
        (fields assetType, campaign, productName, brand, lifecycleStatus,
        usageRights, description; optional captureDate, cameraMake, cameraModel)
      - Lifecycle folder IDs, each shared with the app service account as Editor:
        DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID, LEGAL_REVIEW_FOLDER_ID,
        APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID, RETIRED_FOLDER_ID
      - Enterprise ID: <ENTERPRISE_ID>
      - CCG app scopes enabled: Read and write all files and folders stored in Box,
        Manage AI, and Manage webhooks

      The tutorial's Build by hand samples are Python only. Use those as the behavioral spec for CCG enterprise auth, extract_structured with the metadata template (not inline fields), 409 metadata upsert with JSON-Patch op add, file moves, vendor collaborations, and webhook handling. Adapt the HTTP layer to an ASP.NET Core API controller. Call client.Ai.CreateAiExtractStructuredAsync with a metadata template (TemplateKey + Scope enterprise). Keep listen on port 5000 (urls=http://127.0.0.1:5000).

      The app must:
      1. Authenticate to Box with Client Credentials Grant (enterprise / service account).
         Use BOX_ENTERPRISE_ID — do NOT authenticate as a managed user.
      2. Receive Box FILE.UPLOADED webhook events at POST /webhook. Box expects a 2xx
         response within 30 seconds and retries otherwise, and AI extract can take longer.
         Return 200 right away and run extract and the metadata write in the background
         (an in-process background task is fine here; note in the README that production
         should use a durable queue). Log the extracted fields so the local curl test
         shows them in the server output.
      3. Ignore events for files that are not images Box AI Extract supports
         (JPEG, JPG, PNG, TIFF, TIF, WEBP). Return 200 with a skipped/ignored status.
      4. Call POST /2.0/ai/extract_structured using the brand-asset metadata template
         (template_key + scope enterprise), not inline fields.
      5. Extract assetType, campaign, productName, brand, usageRights, and description.
         Set lifecycleStatus to Draft on intake. Missing fields are OK.
      6. Write the extracted values back to the file as Box enterprise metadata:
         - Try create first.
         - On 409 Conflict (instance already exists), JSON-Patch update with op 'add'
           for each returned field (not 'replace'). Do not fail retests of the same file.
      7. Expose POST /lifecycle that accepts {"file_id", "lifecycle_status"} and:
         - Updates lifecycleStatus on the metadata instance (409-safe upsert).
         - Moves the file into the folder that matches that status.
         Allowed statuses: Draft, Creative review, Legal review, Approved, Published, Retired.
      8. Include a VendorInviteService with a command-line entry point that collaborates an external email
         onto DRAFT_FOLDER_ID as Viewer Uploader (role "viewer uploader"), with notify=true
         and an expires_at date. Treat a 409 with error code user_already_collaborator as
         already invited; do not match on the error message text. In the README, note that
         expires_at requires the enterprise setting 'Automatically remove invited
         collaborators: Allow folder owners to extend the expiry date', that the enterprise
         setting can override the date sent, and to omit expires_at if the setting is off.
      9. Return clear JSON responses and handle Box API errors safely.
      10. Include a local test path that does not require a public webhook URL
          (curl POST to localhost:5000/webhook with a simulated FILE.UPLOADED payload).

      Read configuration from environment variables:
      BOX_CLIENT_ID, BOX_CLIENT_SECRET, BOX_ENTERPRISE_ID,
      BOX_METADATA_TEMPLATE_KEY, DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID,
      LEGAL_REVIEW_FOLDER_ID, APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID,
      RETIRED_FOLDER_ID.
      DRAFT_FOLDER_ID is the intake folder for the production webhook.
      Do not require folder IDs inside POST /webhook except as documented.

      Create a complete runnable project with:
      - Configuration/EnvFileLoader.cs (loads .env at startup), a Box client factory/service, ExtractService, MetadataService, LifecycleService, VendorInviteService, and a webhook controller with POST /webhook and POST /lifecycle
      - an idiomatic ASP.NET Core layout with a root-level web .csproj; keep Box client, extract, metadata, lifecycle, and vendor invite in dedicated services separate from the API controller
      - dependency and run commands
      - .env.example and .gitignore files
      - brief webhook signature verification guidance (link or comment is enough;
        do not block the local curl test on signature checks)
      - idempotency: the 409 metadata upsert above is required
      - focused unit tests and a README with setup and test steps

      Local dev and Run and verify (document in README):
      - Put the runnable web .csproj at the repository root (for example BrandDam.csproj) so `dotnet run` works from the folder the user clones—do not require `cd src/...` only. A nested src/ layout is fine if the root .csproj compiles those sources.
      - Load `.env` at startup before reading BOX_* and folder IDs (tutorial parity with python-dotenv). Walk up from Directory.GetCurrentDirectory() to find `.env`. Never override variables already set in the process environment.
      - README: `cp .env.example .env`, fill every variable, then from repo root `dotnet restore` and `dotnet run`. The API must listen on http://127.0.0.1:5000 (UseUrls or launchSettings).
      - On metadata 409 JSON-Patch updates, UpdateFileMetadataByIdRequestBody.Value is MetadataInstanceValue—convert each field (string, long, double, or string list) before patching.

      Do not invent a Box EXIF API. Camera EXIF (captureDate, cameraMake, cameraModel)
      is optional: download the file and parse it locally (for example MetadataExtractor).
      Read captureDate from DateTimeOriginal in the Exif sub-IFD (0x8769) in preference to
      DateTime in the main block, which is the last-modified time. Skip EXIF when the file
      has none. Box AI Extract reads visual/OCR content only, but it can still fill
      captureDate from visible content; EXIF values overwrite it when present.

      Never hard-code credentials. Add concise comments only where the Box-specific behavior is not obvious. Before writing code, summarize the architecture and list the files you will create.
      ```
    </div>

    <div data-agent-stack=".NET||Minimal APIs" hidden>
      ```md .NET + Minimal APIs theme={null}
      Build a production-minded AI-powered digital asset management (DAM) service by following this Box tutorial:
      https://developer.box.com/tutorials/ai-powered-dam

      Use .NET 8+ with ASP.NET Core Minimal APIs. Use NuGet and exactly one Box package: Box.V2.Core version 10 or later (do NOT also install Box.Sdk.Gen — v10 of Box.V2.Core already ships the generated client). Use BoxClient, BoxCcgAuth, CcgConfig, and BoxApiException (or the SDK's API error type) from that package. Also add a test SDK such as xUnit.

      Prerequisites already done in Box (see 'Before you start' in the tutorial).
      Do NOT try to create these; read their values from environment variables:
      - Enterprise metadata template key: <TEMPLATE_KEY>
        (fields assetType, campaign, productName, brand, lifecycleStatus,
        usageRights, description; optional captureDate, cameraMake, cameraModel)
      - Lifecycle folder IDs, each shared with the app service account as Editor:
        DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID, LEGAL_REVIEW_FOLDER_ID,
        APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID, RETIRED_FOLDER_ID
      - Enterprise ID: <ENTERPRISE_ID>
      - CCG app scopes enabled: Read and write all files and folders stored in Box,
        Manage AI, and Manage webhooks

      The tutorial's Build by hand samples are Python only. Use those as the behavioral spec. Adapt only the HTTP layer to Minimal APIs (MapPost). Call client.Ai.CreateAiExtractStructuredAsync with a metadata template (TemplateKey + Scope enterprise). Keep listen on port 5000 (urls=http://127.0.0.1:5000).

      The app must:
      1. Authenticate to Box with Client Credentials Grant (enterprise / service account).
         Use BOX_ENTERPRISE_ID — do NOT authenticate as a managed user.
      2. Receive Box FILE.UPLOADED webhook events at POST /webhook. Box expects a 2xx
         response within 30 seconds and retries otherwise, and AI extract can take longer.
         Return 200 right away and run extract and the metadata write in the background
         (an in-process background task is fine here; note in the README that production
         should use a durable queue). Log the extracted fields so the local curl test
         shows them in the server output.
      3. Ignore events for files that are not images Box AI Extract supports
         (JPEG, JPG, PNG, TIFF, TIF, WEBP). Return 200 with a skipped/ignored status.
      4. Call POST /2.0/ai/extract_structured using the brand-asset metadata template
         (template_key + scope enterprise), not inline fields.
      5. Extract assetType, campaign, productName, brand, usageRights, and description.
         Set lifecycleStatus to Draft on intake. Missing fields are OK.
      6. Write the extracted values back to the file as Box enterprise metadata:
         - Try create first.
         - On 409 Conflict (instance already exists), JSON-Patch update with op 'add'
           for each returned field (not 'replace'). Do not fail retests of the same file.
      7. Expose POST /lifecycle that accepts {"file_id", "lifecycle_status"} and:
         - Updates lifecycleStatus on the metadata instance (409-safe upsert).
         - Moves the file into the folder that matches that status.
         Allowed statuses: Draft, Creative review, Legal review, Approved, Published, Retired.
      8. Include a VendorInviteService with a command-line entry point that collaborates an external email
         onto DRAFT_FOLDER_ID as Viewer Uploader (role "viewer uploader"), with notify=true
         and an expires_at date. Treat a 409 with error code user_already_collaborator as
         already invited; do not match on the error message text. In the README, note that
         expires_at requires the enterprise setting 'Automatically remove invited
         collaborators: Allow folder owners to extend the expiry date', that the enterprise
         setting can override the date sent, and to omit expires_at if the setting is off.
      9. Return clear JSON responses and handle Box API errors safely.
      10. Include a local test path that does not require a public webhook URL
          (curl POST to localhost:5000/webhook with a simulated FILE.UPLOADED payload).

      Read configuration from environment variables:
      BOX_CLIENT_ID, BOX_CLIENT_SECRET, BOX_ENTERPRISE_ID,
      BOX_METADATA_TEMPLATE_KEY, DRAFT_FOLDER_ID, CREATIVE_REVIEW_FOLDER_ID,
      LEGAL_REVIEW_FOLDER_ID, APPROVED_FOLDER_ID, PUBLISHED_FOLDER_ID,
      RETIRED_FOLDER_ID.
      DRAFT_FOLDER_ID is the intake folder for the production webhook.
      Do not require folder IDs inside POST /webhook except as documented.

      Create a complete runnable project with:
      - Configuration/EnvFileLoader.cs (loads .env at startup), a Box client factory, ExtractService, MetadataService, LifecycleService, VendorInviteService, and Program.cs with MapPost("/webhook", ...) and MapPost("/lifecycle", ...)
      - an idiomatic Minimal APIs layout (MapPost in Program.cs) with a root-level web .csproj; keep Box client, extract, metadata, lifecycle, and vendor invite in dedicated classes separate from endpoint mapping
      - dependency and run commands
      - .env.example and .gitignore files
      - brief webhook signature verification guidance (link or comment is enough;
        do not block the local curl test on signature checks)
      - idempotency: the 409 metadata upsert above is required
      - focused unit tests and a README with setup and test steps

      Local dev and Run and verify (document in README):
      - Put the runnable web .csproj at the repository root (for example BrandDam.csproj) so `dotnet run` works from the folder the user clones—do not require `cd src/...` only. A nested src/ layout is fine if the root .csproj compiles those sources.
      - Load `.env` at startup before reading BOX_* and folder IDs (tutorial parity with python-dotenv). Walk up from Directory.GetCurrentDirectory() to find `.env`. Never override variables already set in the process environment.
      - README: `cp .env.example .env`, fill every variable, then from repo root `dotnet restore` and `dotnet run`. The API must listen on http://127.0.0.1:5000 (UseUrls or launchSettings).
      - On metadata 409 JSON-Patch updates, UpdateFileMetadataByIdRequestBody.Value is MetadataInstanceValue—convert each field (string, long, double, or string list) before patching.

      Do not invent a Box EXIF API. Camera EXIF (captureDate, cameraMake, cameraModel)
      is optional: download the file and parse it locally (for example MetadataExtractor).
      Read captureDate from DateTimeOriginal in the Exif sub-IFD (0x8769) in preference to
      DateTime in the main block, which is the last-modified time. Skip EXIF when the file
      has none. Box AI Extract reads visual/OCR content only, but it can still fill
      captureDate from visible content; EXIF values overwrite it when present.

      Never hard-code credentials. Add concise comments only where the Box-specific behavior is not obvious. Before writing code, summarize the architecture and list the files you will create.
      ```
    </div>
  </div>

  <p className="mt-4 mb-0 text-sm leading-6 text-gray-500 dark:text-gray-400">
    Review generated code before using it in production. Never paste Box credentials into your
    coding agent.
  </p>
</div>

When the agent finishes, copy `.env.example` to `.env` and fill in your client ID, client secret, enterprise ID, metadata template key, and folder IDs. Then skip ahead to [Run and verify](#run-and-verify).

Prefer to write the code yourself? See [Build by hand](#build-by-hand) below.

## Build by hand

Use this path to write the code yourself, or as a reference if the agent drifts. Complete [Before you start](#before-you-start) first, then follow the steps in order. Samples are Python.

You can also clone a working sample if you prefer to start from running code:

<CardGroup cols={2}>
  <Card title="Python working sample" href="https://github.com/box-community/box-ai-powered-dam" icon="github" arrow="true">
    Clone the Flask app, add your Box credentials, and run.
  </Card>
</CardGroup>

<AccordionGroup>
  <Accordion title="1. Set up the development environment">
    1. Open your terminal and create a new project directory:

    ```bash theme={null}
    mkdir brand-dam && cd brand-dam
    ```

    2. Install dependencies:

    ```bash theme={null}
    python3 -m venv .venv
    source .venv/bin/activate
    pip install "boxsdk~=10.0" flask python-dotenv
    ```

    <Note>
      After activation, your terminal prompt shows `(.venv)` at the beginning. Every time you open a new terminal window or tab, re-activate the virtual environment with `source .venv/bin/activate` from the project directory. If you see `ModuleNotFoundError`, the venv is usually not activated.
    </Note>

    3. Create a `.env` file and add the following content. Replace the placeholder values with credentials from the Box Developer Console and the folder IDs from [Before you start](#before-you-start):

    ```bash theme={null}
    BOX_CLIENT_ID=your_client_id
    BOX_CLIENT_SECRET=your_client_secret
    BOX_ENTERPRISE_ID=your_enterprise_id
    BOX_METADATA_TEMPLATE_KEY=your_metadata_template_key
    DRAFT_FOLDER_ID=your_draft_folder_id
    CREATIVE_REVIEW_FOLDER_ID=your_creative_review_folder_id
    LEGAL_REVIEW_FOLDER_ID=your_legal_review_folder_id
    APPROVED_FOLDER_ID=your_approved_folder_id
    PUBLISHED_FOLDER_ID=your_published_folder_id
    RETIRED_FOLDER_ID=your_retired_folder_id
    ```

    <Warning>
      Never commit `.env` files to version control. Add `.env` to your `.gitignore`.
    </Warning>

    <Note>
      The `.env` file stores sensitive values. Your code reads them by **name**. When you copy the code in the following steps, keep the quoted variable names exactly as shown. Do not replace them with your actual credentials.
    </Note>
  </Accordion>

  <Accordion title="2. Authenticate the Box client">
    Create the Box client module in your project directory:

    ```python theme={null}
    # box_client.py
    import os
    from dotenv import load_dotenv
    from box_sdk_gen import (
        BoxClient,
        BoxCCGAuth,
        CCGConfig,
    )

    load_dotenv()

    def get_box_client() -> BoxClient:
        config = CCGConfig(
            client_id=os.getenv("BOX_CLIENT_ID"),
            client_secret=os.getenv("BOX_CLIENT_SECRET"),
            enterprise_id=os.getenv("BOX_ENTERPRISE_ID"),
        )
        auth = BoxCCGAuth(config=config)
        return BoxClient(auth=auth)
    ```

    <Tip>
      Client Credentials Grant is recommended for server-to-server automations where no end user is present. For other authentication options, see <Link href="/guides/authentication/select">Select an authentication method</Link>.
    </Tip>
  </Accordion>

  <Accordion title="3. Build the extraction function">
    Create the extraction module. It takes a file ID, calls Box AI with your metadata template, and returns structured brand fields. Structured extract runs OCR on JPEG, PNG, TIFF, and WEBP, so you do not convert product shots or logos to PDF first. See <Link href="/guides/box-ai/ai-tutorials/extract-metadata-structured">Extract metadata from file (structured)</Link>.

    ```python theme={null}
    # extract.py
    import os
    from dotenv import load_dotenv
    from box_sdk_gen import (
        AiItemBase,
        BoxClient,
        CreateAiExtractStructuredMetadataTemplate,
        CreateAiExtractStructuredMetadataTemplateTypeField,
    )

    load_dotenv()

    def extract_brand_fields(client: BoxClient, file_id: str) -> dict:
        template_key = os.getenv("BOX_METADATA_TEMPLATE_KEY")

        result = client.ai.create_ai_extract_structured(
            items=[AiItemBase(id=file_id)],
            metadata_template=CreateAiExtractStructuredMetadataTemplate(
                template_key=template_key,
                type=CreateAiExtractStructuredMetadataTemplateTypeField.METADATA_TEMPLATE,
                scope="enterprise",
            ),
        )

        return result.to_dict()["answer"]
    ```

    The template tells Box AI which fields to look for. Product name, campaign lettering, and brand marks come back as keys, not a free-text caption.
  </Accordion>

  <Accordion title="4. Write metadata back to the file">
    Create the metadata module. After extraction, this function attaches the values to the file as a metadata instance.

    ```python theme={null}
    # metadata.py
    import os
    from dotenv import load_dotenv
    from box_sdk_gen import (
        BoxAPIError,
        BoxClient,
        CreateFileMetadataByIdScope,
        UpdateFileMetadataByIdRequestBody,
        UpdateFileMetadataByIdRequestBodyOpField,
        UpdateFileMetadataByIdScope,
    )

    load_dotenv()

    def apply_metadata(client: BoxClient, file_id: str, metadata: dict) -> dict:
        template_key = os.getenv("BOX_METADATA_TEMPLATE_KEY")

        try:
            attached = client.file_metadata.create_file_metadata_by_id(
                file_id=file_id,
                scope=CreateFileMetadataByIdScope.ENTERPRISE,
                template_key=template_key,
                request_body=metadata,
            )
        except BoxAPIError as error:
            if error.response_info.status_code != 409:
                raise

            attached = client.file_metadata.update_file_metadata_by_id(
                file_id=file_id,
                scope=UpdateFileMetadataByIdScope.ENTERPRISE,
                template_key=template_key,
                request_body=[
                    UpdateFileMetadataByIdRequestBody(
                        op=UpdateFileMetadataByIdRequestBodyOpField.ADD,
                        path=f"/{key}",
                        value=value,
                    )
                    for key, value in metadata.items()
                ],
            )

        return attached.to_dict()
    ```

    <Note>
      Creating metadata succeeds only the first time. If the file already has an instance of the template, Box returns `409 Conflict on Metadata Instance`. This function then sends a JSON-Patch update. You can re-run the service on a file you already processed, including duplicate webhook deliveries.

      Use the `add` operation, not `replace`. `add` sets a value whether or not the field is already present. The update still succeeds when Box AI returns fewer fields than the previous run.
    </Note>

    After metadata is attached, the fields are searchable and visible in the Box web app. Use <Link href="/guides/metadata/queries">metadata queries</Link> to find External-rights logos for a campaign, or to list files still in Legal review.
  </Accordion>

  <Accordion title="5. Advance the brand asset lifecycle">
    Create the lifecycle module. Each allowed status maps to a folder. The function updates status and moves the file in one step so current files do not sit in the wrong folder.

    ```python theme={null}
    # lifecycle.py
    import os
    from box_sdk_gen import BoxClient, UpdateFileByIdParent

    from metadata import apply_metadata

    STATUS_FOLDER_ENV = {
        "Draft": "DRAFT_FOLDER_ID",
        "Creative review": "CREATIVE_REVIEW_FOLDER_ID",
        "Legal review": "LEGAL_REVIEW_FOLDER_ID",
        "Approved": "APPROVED_FOLDER_ID",
        "Published": "PUBLISHED_FOLDER_ID",
        "Retired": "RETIRED_FOLDER_ID",
    }

    def advance_lifecycle(
        client: BoxClient, file_id: str, lifecycle_status: str
    ) -> dict:
        env_name = STATUS_FOLDER_ENV.get(lifecycle_status)
        if not env_name:
            raise ValueError(
                f"Unknown lifecycle status: {lifecycle_status}. "
                f"Allowed: {', '.join(STATUS_FOLDER_ENV)}"
            )

        folder_id = os.getenv(env_name)
        apply_metadata(client, file_id, {"lifecycleStatus": lifecycle_status})
        client.files.update_file_by_id(
            file_id, parent=UpdateFileByIdParent(id=folder_id)
        )
        return {"file_id": file_id, "lifecycle_status": lifecycle_status, "folder_id": folder_id}
    ```

    Creative and legal teams can still review in Box with comments, versions, tasks, or a <Link href="/guides/box-automate">Box Automate</Link> approval workflow. This function records which folder holds the current file after that decision.
  </Accordion>

  <Accordion title="6. Invite an external vendor to Draft">
    Agencies and freelancers must not see Legal review or Published masters. Invite them to Draft only, as Viewer Uploader, with an expiration date.

    ```python theme={null}
    # invite_vendor.py
    import os
    from datetime import datetime, timedelta, timezone

    from dotenv import load_dotenv
    from box_sdk_gen import (
        BoxAPIError,
        CreateCollaborationAccessibleBy,
        CreateCollaborationAccessibleByTypeField,
        CreateCollaborationItem,
        CreateCollaborationItemTypeField,
        CreateCollaborationRole,
    )

    from box_client import get_box_client

    load_dotenv()

    def invite_vendor(login: str, days: int = 30) -> dict:
        client = get_box_client()
        folder_id = os.getenv("DRAFT_FOLDER_ID")
        expires_at = datetime.now(timezone.utc) + timedelta(days=days)

        try:
            collaboration = client.user_collaborations.create_collaboration(
                item=CreateCollaborationItem(
                    type=CreateCollaborationItemTypeField.FOLDER,
                    id=folder_id,
                ),
                accessible_by=CreateCollaborationAccessibleBy(
                    type=CreateCollaborationAccessibleByTypeField.USER,
                    login=login,
                ),
                role=CreateCollaborationRole.VIEWER_UPLOADER,
                expires_at=expires_at,
                notify=True,
            )
        except BoxAPIError as error:
            if error.response_info.code != "user_already_collaborator":
                raise
            return {"status": "already_collaborator", "login": login}

        return collaboration.to_dict()

    if __name__ == "__main__":
        import sys

        email = sys.argv[1] if len(sys.argv) > 1 else os.getenv("VENDOR_EMAIL")
        if not email:
            raise SystemExit("Pass a vendor email or set VENDOR_EMAIL")
        print(invite_vendor(email))
    ```

    <Warning>
      Do not put unpublished brand files in a folder that already has an open shared link, or that already has external collaborators outside Draft. Invite named users. Set an expiration on the collaboration. Add only the Published folder to a Hub.
    </Warning>

    `expires_at` depends on the **Automatically remove invited collaborators: Allow folder owners to extend the expiry date** setting in the Admin Console **Enterprise Settings**. If that setting is off, omit `expires_at`. The enterprise setting can also override the date you send, so check the expiration on the new collaboration in Box. External email invites can also require the vendor domain on the <Link href="/guides/collaborations/allowed-domains">collaboration allowlist</Link>.

    For role definitions, see <Link href="/guides/collaborations/share-content">Share content with a user</Link>.
  </Accordion>

  <Accordion title="7. Create the webhook listener">
    Create the HTTP application. It receives webhook notifications when new files arrive in Draft. It also exposes a lifecycle endpoint your review app can call.

    ```python theme={null}
    # app.py
    import json

    from dotenv import load_dotenv
    from flask import Flask, jsonify, request

    from box_client import get_box_client
    from extract import extract_brand_fields
    from lifecycle import advance_lifecycle
    from metadata import apply_metadata

    load_dotenv()
    app = Flask(__name__)

    IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp")

    @app.route("/webhook", methods=["POST"])
    def handle_webhook():
        payload = request.get_json()

        if payload.get("trigger") != "FILE.UPLOADED":
            return jsonify({"status": "ignored"}), 200

        file_id = payload["source"]["id"]
        file_name = payload["source"]["name"]

        if not file_name.lower().endswith(IMAGE_EXTENSIONS):
            return jsonify({"status": "skipped, not a supported image"}), 200

        client = get_box_client()
        extracted = extract_brand_fields(client, file_id)
        extracted["lifecycleStatus"] = "Draft"
        print(f"Extracted from {file_name}: {json.dumps(extracted, indent=2)}")

        apply_metadata(client, file_id, extracted)
        print(f"Metadata applied to file {file_id}")

        return jsonify({"status": "processed", "file_id": file_id}), 200

    @app.route("/lifecycle", methods=["POST"])
    def handle_lifecycle():
        body = request.get_json()
        file_id = body["file_id"]
        lifecycle_status = body["lifecycle_status"]

        client = get_box_client()
        result = advance_lifecycle(client, file_id, lifecycle_status)
        return jsonify(result), 200

    if __name__ == "__main__":
        app.run(port=5000)
    ```

    <Note>
      In production, verify webhook signatures to confirm that requests originate from Box. See <Link href="/guides/webhooks/v2/signatures-v2">Verify webhook signatures</Link>.
    </Note>

    <Warning>
      This handler runs Box AI extraction before it responds, which keeps the local test simple. Box expects a `2xx` response within 30 seconds and retries the delivery if it does not get one. Extraction can take long enough to miss that window. In production, return `200` right away and process the file from a queue. See **Respond before processing** in [Scaling to production](#scaling-to-production).
    </Warning>

    After scaffolding, your project directory contains these files:

    ```text theme={null}
    brand-dam/
    ├── .env
    ├── .venv/
    ├── app.py
    ├── box_client.py
    ├── extract.py
    ├── invite_vendor.py
    ├── lifecycle.py
    └── metadata.py
    ```
  </Accordion>
</AccordionGroup>

When you finish scaffolding, continue to [Run and verify](#run-and-verify).

## Run and verify

Test extraction locally without a public URL or webhook. The request body matches what Box sends when a new file arrives.

<Note>
  Open two terminal windows at the same time. Terminal 1 runs the server and must stay running. Terminal 2 sends a test request.
</Note>

1. **Terminal 1.** Start the server. Make sure you are in the `brand-dam` directory:

   <CodeGroup>
     ```bash Python theme={null}
     # Flask
     cd ~/brand-dam
     source .venv/bin/activate
     python3 app.py

     # FastAPI (idiomatic package layout)
     cd ~/brand-dam
     source .venv/bin/activate
     uvicorn app.main:app --host 127.0.0.1 --port 5000
     ```

     ```bash TypeScript theme={null}
     # Express (flat tutorial layout)
     cd ~/brand-dam
     npx tsx app.ts

     # Fastify (idiomatic src/ layout)
     cd ~/brand-dam
     npm start
     ```

     ```bash Node theme={null}
     # Express
     cd ~/brand-dam
     node app.js

     # NestJS
     cd ~/brand-dam
     npm start
     ```

     ```bash Java theme={null}
     # Spring Boot
     cd ~/brand-dam
     export PATH="/opt/homebrew/opt/openjdk@17/bin:$PATH"   # if needed
     mvn spring-boot:run

     # Quarkus
     cd ~/brand-dam
     export PATH="/opt/homebrew/opt/openjdk@17/bin:$PATH"   # if needed
     mvn quarkus:dev
     ```

     ```bash .NET theme={null}
     # ASP.NET Core or Minimal APIs
     cd ~/brand-dam
     dotnet run
     ```
   </CodeGroup>

   The server listens on port 5000. Leave this terminal running.

2. **Terminal 2.** Send a test request. Upload a JPEG or PNG product image to the Draft folder in Box. Copy the file ID from the file URL. For `https://app.box.com/file/123456789`, the file ID is `123456789`.

   <Warning>
     Use the file ID, not the folder ID.
   </Warning>

   ```bash theme={null}
   curl -X POST http://127.0.0.1:5000/webhook \
     -H "Content-Type: application/json" \
     -d '{
       "trigger": "FILE.UPLOADED",
       "source": {
         "id": "<FILE_ID>",
         "name": "autumn-campaign-hero.jpg"
       }
     }'
   ```

3. **Check the result.** Switch back to Terminal 1. The extracted fields print, then a confirmation that metadata was applied:

   ```text theme={null}
   Extracted from autumn-campaign-hero.jpg: {
     "assetType": "Product imagery",
     "campaign": "Autumn 2026",
     "productName": "Trail Runner",
     "brand": "Northwind",
     "usageRights": "External",
     "description": "Hero pack shot of Trail Runner sneakers on a trail",
     "lifecycleStatus": "Draft"
   }
   Metadata applied to file 123456789
   ```

   Open the file in Box and select the **Metadata** tab to verify the values.

4. **Advance the file.** After creative and legal sign-off, move it to Published.

   ```bash theme={null}
   curl -X POST http://127.0.0.1:5000/lifecycle \
     -H "Content-Type: application/json" \
     -d '{
       "file_id": "<FILE_ID>",
       "lifecycle_status": "Published"
     }'
   ```

   Confirm the file is in the **Published** folder and `lifecycleStatus` is `Published`.

## Register a webhook for production

The local curl test matches what Box sends. For production, Box needs a public HTTPS endpoint.

```bash theme={null}
curl -X POST https://api.box.com/2.0/webhooks \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "target": {
      "id": "<DRAFT_FOLDER_ID>",
      "type": "folder"
    },
    "address": "<webhook_url>",
    "triggers": ["FILE.UPLOADED"]
  }'
```

Replace `<DRAFT_FOLDER_ID>` with your Draft folder ID and set `address` to your HTTPS URL with `/webhook` appended.

Once registered, a supported image uploaded to Draft triggers extraction and metadata writes.

## Publish approved assets to a Box Hub

After files reach Published, add that folder to a Hub. Regional teams and agency partners then search and ask questions against files approved for external use, not drafts. Box Hubs inherit permissions from the source files.

Create a file called `publish_hub.py`:

```python theme={null}
import os
from dotenv import load_dotenv
from box_sdk_gen import (
    FolderReferenceV2025R0,
    HubItemOperationV2025R0,
    HubItemOperationV2025R0ActionField,
)

from box_client import get_box_client

load_dotenv()

def main():
    client = get_box_client()
    hub = client.hubs.create_hub_v2025_r0(
        "Brand asset library",
        description=(
            "Approved product imagery, logos, and marketing materials. "
            "Use only files in this hub for external distribution."
        ),
    )
    client.hub_items.manage_hub_items_v2025_r0(
        hub.id,
        operations=[
            HubItemOperationV2025R0(
                action=HubItemOperationV2025R0ActionField.ADD,
                item=FolderReferenceV2025R0(id=os.getenv("PUBLISHED_FOLDER_ID")),
            )
        ],
    )
    print(f"Hub ready: {hub.title} (ID: {hub.id})")

if __name__ == "__main__":
    main()
```

Run the script once from the project directory, with your virtual environment active and `.env` loaded:

```bash theme={null}
python3 publish_hub.py
```

A successful run prints `Hub ready: Brand asset library (ID: …)`. Open `https://app.box.com/hubs/<HUB_ID>`. Files you later move to Published with `POST /lifecycle` show up in this hub. Do not run the script again for each file; that creates another hub.

The CCG service account owns the hub. **Hubs** in the Box web app lists hubs for the signed-in user, so this hub does not appear there until you <Link href="/guides/hubs-api/hubs-collaborations/hub-collaborations">collaborate</Link> your managed user onto it (for example as co-owner).

Hub endpoints require the `box-version: 2025.0` header. The v10 SDK methods above send it. See <Link href="/guides/hubs-api">Box Hubs overview</Link> and the <Link href="/tutorials/sales-rfp-answer-bank">RFP answer bank tutorial</Link> to ask questions against hub content.

<Note>
  Creating a hub as the CCG service account requires Box Hubs to be enabled for that account. If create fails, enable **Generate user access tokens** and authenticate as a managed user who can create hubs. The answer bank tutorial shows that auth path.
</Note>

## Optional: parse camera EXIF locally

Box does not expose camera EXIF through the metadata or AI APIs. Box AI Extract covers visual and OCR fields. If you need capture date or camera model, download the file, parse EXIF locally, and merge those values into the same metadata instance.

1. From your project directory, activate the virtual environment and install Pillow:

   ```bash theme={null}
   source .venv/bin/activate
   pip install pillow
   ```

   Install into this venv, not the system Python on your machine.

2. Create `exif.py`:

   ```python theme={null}
   import io
   from datetime import datetime

   from box_sdk_gen import BoxClient
   from PIL import Image
   from PIL.ExifTags import TAGS

   def enrich_exif(client: BoxClient, file_id: str) -> dict:
       content = client.downloads.download_file(file_id)
       data = content.read() if hasattr(content, "read") else content
       image = Image.open(io.BytesIO(data))
       raw = image.getexif()
       if not raw:
           return {}

       tagged = {TAGS.get(key, key): value for key, value in raw.items()}
       # DateTimeOriginal is in the Exif sub-IFD (0x8769), not the main block
       tagged.update({TAGS.get(key, key): value for key, value in raw.get_ifd(0x8769).items()})
       enrichment = {}

       make = tagged.get("Make")
       model = tagged.get("Model")
       captured = tagged.get("DateTimeOriginal") or tagged.get("DateTime")

       if make:
           enrichment["cameraMake"] = str(make).strip()
       if model:
           enrichment["cameraModel"] = str(model).strip()
       if captured:
           try:
               parsed = datetime.strptime(str(captured), "%Y:%m:%d %H:%M:%S")
               enrichment["captureDate"] = parsed.strftime("%Y-%m-%dT00:00:00Z")
           except ValueError:
               pass

       return enrichment
   ```

3. If you built by hand: in `app.py`, import `enrich_exif` and merge its return value into `extracted` after Box AI extract and before `apply_metadata`:

   ```python theme={null}
   from exif import enrich_exif
   ```

   Inside `handle_webhook`, after you set `lifecycleStatus` to `Draft`:

   ```python theme={null}
   try:
       extracted.update(enrich_exif(client, file_id) or {})
   except Exception:
       pass
   ```

   If you used the agent path, your project may already include these lines. Search `app.py` for `enrich_exif`. You still need Pillow in the venv.

4. Test with the same local `POST /webhook` request from [Run and verify](#run-and-verify). Use a camera or phone JPEG that contains EXIF, not a logo PNG with no camera data. In the server log, look for `cameraMake`, `cameraModel`, and `captureDate` alongside the Box AI fields. Confirm the same values on the file **Metadata** tab in Box.

   Logos and exports without EXIF leave those three fields empty. That is expected.

## Optional: retire large-format media to Box Archive

The Retired folder keeps superseded campaigns out of the Hub. Use <Link href="/guides/archives">Box Archive</Link> when large video or print masters should be owned by the enterprise, not the previous user. Archive APIs require Enterprise Advanced. They also require the Global Content Manager (GCM) scope, which Box Support enables. They are not available on the Free Developer Plan.

To archive a retired file, create an archive with <Link href="/reference/v2025.0/post-archives">`POST /archives`</Link>,
then <Link href="/guides/archives/add-content">move the file</Link> by setting `parent.id` to the archive ID.

Availability, scopes, and which endpoints your app can call depend on your Box plan and enterprise configuration. Confirm those in the guides below before you extend this service:

<RelatedLinks
  items={[
{ label: translate("Box Archive overview"), href: "/guides/archives", badge: "GUIDE" },
{ label: translate("Add content to Box Archive"), href: "/guides/archives/add-content", badge: "GUIDE" },
{ label: translate("Restore content from Box Archive"), href: "/guides/archives/restore-content", badge: "GUIDE" },
{ label: translate("Supported APIs for Box Archive"), href: "/guides/archives/supported-apis", badge: "GUIDE" },
{ label: translate("Create archive"), href: "/reference/v2025.0/post-archives", badge: "POST" },
]}
/>

## Scaling to production

<AccordionGroup>
  <Accordion title="Respond before processing">
    Box waits 30 seconds for a `2xx` response to each webhook delivery. If it does not get one, it retries the delivery. Box AI extraction can take several seconds per image, and longer on large files, so the inline handler in `app.py` can time out and trigger retries that call Box AI again.

    In production, validate the payload, add the file ID to a job queue, and return `200` immediately. A separate worker runs extraction and writes metadata. See <Link href="/guides/webhooks/v2">Webhooks (v2)</Link> for delivery and retry behavior.
  </Accordion>

  <Accordion title="Handle duplicate deliveries">
    Box webhook delivery can produce duplicates. The 409 fallback in `apply_metadata` keeps the write safe, but a duplicate still calls Box AI a second time. To skip that work, check for an existing instance with `GET /2.0/files/:id/metadata/enterprise/:template` at the top of the handler. Return early if `lifecycleStatus` is already set.

    If a vendor replaces a draft in place, let extraction run so metadata matches the latest version.
  </Accordion>

  <Accordion title="Keep vendor access off published masters">
    Collaborate external users on Draft only. Do not cascade Editor access from `Brand DAM` to vendors. If an agency needs a specific published file, collaborate on that file or on a Hub, not the whole tree.
  </Accordion>

  <Accordion title="Process at scale with event streams">
    For high-volume drop folders, use <Link href="/guides/events/enterprise-events/for-enterprise">enterprise events</Link> rather than webhooks. Enterprise events are a polling stream that holds events until you consume them. That works for batch processing thousands of files.
  </Accordion>
</AccordionGroup>

## Troubleshooting

<AccordionGroup>
  <Accordion title="ModuleNotFoundError: No module named '...'">
    Your Python virtual environment is not activated. Run `source .venv/bin/activate` from the project directory before running any `python3` commands. Each new terminal tab needs its own activation.
  </Accordion>

  <Accordion title="invalid_client: The client credentials are invalid">
    Check your `.env` file:

    * Verify `BOX_CLIENT_ID` and `BOX_CLIENT_SECRET` match Developer Console > Configuration.
    * Confirm `BOX_ENTERPRISE_ID` is your enterprise ID (Admin Console > Account & Billing, or Developer Console > icon in the top-right > Copy Enterprise ID).
    * Ensure your app is authorized and the app type is Client Credentials Grant.
  </Accordion>

  <Accordion title="404 Not Found">
    The service account does not have access to the file or folder. Invite the service account email (Developer Console > your app > **Configuration** > **App Details** sidebar) as a collaborator with **Editor** on `Brand DAM`.
  </Accordion>

  <Accordion title="metadata_template must have both scope and template_key">
    The `BOX_METADATA_TEMPLATE_KEY` value in your `.env` file is missing or empty. Add the template key you noted in [Before you start](#before-you-start).
  </Accordion>

  <Accordion title="409 Conflict on Metadata Instance">
    The file already has an instance of the template. Add the 409 fallback shown in **4. Write metadata back to the file**. To retest with a clean file, remove the instance from the file's **Metadata** tab, or delete it through the API:

    ```bash theme={null}
    curl -X DELETE "https://api.box.com/2.0/files/<FILE_ID>/metadata/enterprise/<TEMPLATE_KEY>" \
      -H "Authorization: Bearer <ACCESS_TOKEN>"
    ```
  </Accordion>

  <Accordion title="Unknown lifecycle status">
    `POST /lifecycle` only accepts the six dropdown values from the template, including the space in `Creative review` and `Legal review`. Check the JSON body against those strings exactly.
  </Accordion>

  <Accordion title="The agent tried to create the template or folders">
    The template, folders, and enterprise ID come from [Before you start](#before-you-start). An agent cannot create the Admin Console template. Re-paste the prompt with the prerequisites block intact, or follow [Build by hand](#build-by-hand).
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Sales RFP answer bank" href={localizeLink("/tutorials/sales-rfp-answer-bank")} icon="magnifying-glass" arrow="true">
    Query a curated Hub with Box AI after you publish approved assets.
  </Card>

  <Card title="Extract API reference" href={localizeLink("/reference/post-ai-extract-structured")} icon="code" arrow="true">
    See the full API specification for structured extraction, including image formats.
  </Card>
</CardGroup>

<RelatedLinks
  title="RELATED GUIDES"
  items={[
{ label: translate("Extract metadata from file (structured)"), href: "/guides/box-ai/ai-tutorials/extract-metadata-structured", badge: "GUIDE" },
{ label: translate("Share content with a user"), href: "/guides/collaborations/share-content", badge: "GUIDE" },
{ label: translate("Box Hubs overview"), href: "/guides/hubs-api/index", badge: "GUIDE" },
{ label: translate("Add content to Box Archive"), href: "/guides/archives/add-content", badge: "GUIDE" },
{ label: translate("Working with metadata"), href: "/guides/metadata/index", badge: "GUIDE" },
{ label: translate("Verify webhook signatures"), href: "/guides/webhooks/v2/signatures-v2", badge: "GUIDE" }
]}
/>
