Customize a Generated Handler customize-generated-handler

IMPORTANT
Adobe LLM Apps is currently in Beta.
Features, workflows, and UI shown here do not necessarily represent the final state of the product. To join the Beta, send an email to llm-apps-beta@adobe.com.

The platform creates a working handler for every generated action. The handler initially returns sample data so you can test the complete experience.

Use this guide to understand the handler contract and replace the sample data with your APIs or data sources.

Journey: Find the generated handler → understand its inputs and result → connect your system → keep the widget contract aligned → test and deploy.

Find the generated handler

Open the handler repository selected during onboarding:

actions/
└── <action-name>/
    └── index.js

The matching tests are stored separately:

test/
└── actions/
    └── <action-name>.test.js

Edit the generated index.js. Do not change runtime files such as entry.js.

Handler contract

Each handler exports one asynchronous function:

module.exports = async (args) => {
  return {
    content: [
      { type: 'text', text: 'Response for the LLM platform.' }
    ],
    structuredContent: {
      // Data for the widget.
    }
  };
};

The function receives an args object and returns a result object.

Input: args

args contains the parameters defined for the action in LLM Apps.

For an action with category and query parameters:

module.exports = async ({ category = '', query = '' } = {}) => {
  // Use the validated action arguments.
};

The runtime validates the input schema when the action metadata includes inputSchema, as it does after deployment. Local handler discovery without actions.json does not apply schema validation. The handler should always enforce business rules such as supported values, maximum lengths, and allowed combinations.

Output: content

Always return content. It is an array of content parts read by the LLM platform and by hosts that do not display widgets.

content: [
  {
    type: 'text',
    text: 'Found 3 products matching your search.'
  }
]

Keep this response concise. Do not include credentials, internal errors, or data the user is not authorized to see.

Output: structuredContent

Return structuredContent when the action has a widget. It must be a plain object, not a bare array.

structuredContent: {
  products: [
    {
      id: 'P-100',
      name: 'Frescopa House Blend',
      price: '$14.99'
    }
  ],
  total: 1
}

structuredContent is sent to the widget, not to the LLM. Return only the fields required by the interface.

For a text-only action, structuredContent can be omitted.

The handler–widget contract

The handler and widget share one contract: the shape of structuredContent.

Action arguments
      ↓
Handler
      ├── content → LLM text response
      └── structuredContent → Widget
                                  ↓
                           bridge.toolResult

The widget reads the handler result from the LLM Apps SDK bridge:

export default async function decorate(block, bridge) {
  const result = await bridge.toolResult;
  const products = result?.structuredContent?.products ?? [];

  // Render products.
}

If the handler returns:

structuredContent: {
  products: [...],
  total: 3
}

the widget must read structuredContent.products and structuredContent.total.

Changing a field name or type can break the widget. Update the handler, widget, and tests together.

Replace sample data

Generated handlers usually contain an in-memory sample array. Replace that data lookup with a server-side call to your system.

const API_ORIGIN = process.env.PRODUCT_API_ORIGIN;
const API_TOKEN = process.env.PRODUCT_API_TOKEN;

module.exports = async ({ query = '' } = {}) => {
  const normalizedQuery = String(query).trim();
  if (!normalizedQuery || normalizedQuery.length > 200) {
    return {
      content: [{ type: 'text', text: 'Enter a valid product search.' }],
      structuredContent: { products: [], total: 0 }
    };
  }

  if (!API_ORIGIN || !API_TOKEN) {
    throw new Error('Product API configuration is unavailable.');
  }

  const origin = new URL(API_ORIGIN);
  if (origin.protocol !== 'https:') {
    throw new Error('Product API configuration must use HTTPS.');
  }

  const url = new URL('/v1/products', origin);
  url.searchParams.set('query', normalizedQuery);

  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${API_TOKEN}` },
    signal: AbortSignal.timeout(8000)
  });

  if (!response.ok) {
    throw new Error('Product service request failed.');
  }

  const payload = await response.json();
  if (!payload || !Array.isArray(payload.products)
      || !payload.products.every((product) =>
        product
        && typeof product.id === 'string'
        && typeof product.name === 'string'
        && typeof product.price === 'string')) {
    throw new Error('Product service returned an unexpected response.');
  }

  const products = payload.products.map(({ id, name, price }) => ({
    id,
    name,
    price
  }));

  return {
    content: [
      { type: 'text', text: `Found ${products.length} matching products.` }
    ],
    structuredContent: {
      products,
      total: products.length
    }
  };
};

Keep protected network access in the handler. Never put API credentials in widget JavaScript or source control.

Handle expected states

Preserve a predictable output shape for every result.

Results found

{
  content: [{ type: 'text', text: 'Found 3 products.' }],
  structuredContent: { products: [...], total: 3 }
}

No results

{
  content: [{ type: 'text', text: 'No matching products were found.' }],
  structuredContent: { products: [], total: 0 }
}

The widget can now render an empty state without guessing whether products exists.

For service failures, return or throw a safe error without exposing stack traces, tokens, internal hosts, or upstream response bodies.

Test the contract

Update the generated tests whenever the handler changes. Cover:

  • Valid and invalid arguments.
  • Results and no-results states.
  • API failures and timeouts.
  • Malformed API responses.
  • content is always present.
  • structuredContent is a plain object.
  • The shape expected by the widget.

Run:

npm test

For local MCP testing, see Local handler development and testing.

Deploy the change

  1. Commit and push the handler changes.
  2. If the data shape changed, update and push the widget.
  3. Deploy the app to Stage.
  4. Test the ChatGPT plugin.
  5. After Stage succeeds, deploy to Production.

Next, see Customize a generated widget.

recommendation-more-help
llm-apps-help-main-toc