Write the Action 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.

After creating an action in the Adobe LLM Apps UI, the metadata is stored in the LLM Apps API — but there is no code behind it yet. This guide walks you through writing the handler function that runs when an LLM platform (such as ChatGPT or Claude) invokes your action.

For project layout, local development, and testing details, see Development.

Developer contract

You write handlers only. Everything else — action name, description, input schema, annotations, widget visibility, permissions, CSP — lives in the LLM Apps UI and is delivered to the runtime automatically at deploy time. You never hand-edit metadata in your repository and you never register a tool in code.

Concern
Where it lives
Metadata (name, description, schema, widget settings)
LLM Apps UI — saved in the API
Handler code (the function that runs)
Your GitHub repository — actions/<name>/index.js
actions.json (metadata snapshot)
Written by the deploy pipeline; downloaded from the UI for local development

Getting started

Your linked repository needs the project structure before you can write handlers. Clone the Adobe LLM Apps boilerplate to get started with an empty starting point.

Push the contents to the repository you linked during app creation (for example, your-org/your-repo).

Once the code is in place, run:

npm install

This installs all dependencies, including @adobe/llm-apps-runtime — the runtime that handles MCP protocol communication, action discovery, and request routing. You do not interact with the runtime directly; it is consumed by entry.js at build time.

TIP
If you use Claude Code or Cursor, the boilerplate includes a ready-to-use Claude skill at .claude/skills/llm-apps-action-author/. It can scaffold new actions, generate test files, validate handler shapes, and guide you through the handler contract — all from your editor. To use it, ask Claude to “add an action called search-products” and it will follow the correct project conventions automatically.

Handler contract

A handler is a single file at actions/<name>/index.js that exports one async function:

module.exports = async (args) => {
  return {
    content: [{ type: 'text', text: 'response for the LLM' }],
    structuredContent: { /* data for the widget */ }
  }
}

The function receives the action’s input arguments as a plain object — these are the parameters you defined in the Create Action dialog. The server validates them against the input schema before your handler is called.

content (required)

An array of content parts sent to the LLM and text-only hosts. This is what the LLM platform reads to formulate its response.

content: [
  { type: 'text', text: 'Found 5 products matching category "bagged-coffee".' }
]

Always return content — it is the universal fallback for any host.

structuredContent

A plain JavaScript object sent to the widget. This data has zero token cost — it is consumed by the EDS widget block to render a rich UI like a product carousel or a map.

structuredContent: {
  products: [
    { name: 'Product A', category: 'bagged-coffee', imageUrl: '...' },
    { name: 'Product B', category: 'bagged-coffee', imageUrl: '...' }
  ],
  total: 2,
  category: 'bagged-coffee'
}

The structure is up to you — it must match what your EDS widget block expects via bridge.toolResult.

IMPORTANT
structuredContent must be a plain object, not a bare array.

_meta (optional)

Additional metadata sent alongside the result. The openai/widgetDescription key tells the LLM platform how to present the widget:

_meta: {
  'openai/widgetDescription': 'The widget displays a scrollable product carousel. '
    + 'Do NOT repeat the product list. Instead, highlight one or two recommendations.'
}

Example: Search Products handler

Here is a search-products handler example. It accepts an optional category filter and a free-text query, searches a product catalog, and returns both a text summary for the LLM and structured data for the widget carousel.

NOTE
This example uses a hardcoded product array for simplicity. In a real application you would typically call your own product API or database to fetch results dynamically.
// actions/search-products/index.js

const PRODUCTS = [
  {
    name: 'Product A',
    description: 'A short description of Product A.',
    category: 'bagged-coffee',
    sub_category: 'dark-roast',
    image_url: 'https://www.example.com/products/product-a/hero.jpg',
    url: 'https://www.example.com/products/product-a',
    productId: 'PROD-001',
    rating: 4.7,
    reviewCount: 58
  },
  // ... more products
];

const WIDGET_DESCRIPTION = 'The widget displays a scrollable product carousel '
  + 'with images, star ratings, and review counts. Do NOT repeat the product list.';

module.exports = async ({ category = '', query = '' } = {}) => {
  let results = PRODUCTS;

  if (category) {
    const categoryLower = category.toLowerCase();
    results = results.filter((p) =>
      p.category.toLowerCase().includes(categoryLower)
      || p.sub_category.toLowerCase().includes(categoryLower)
    );
  }

  if (query) {
    const queryLower = query.toLowerCase();
    results = results.filter((p) =>
      p.name.toLowerCase().includes(queryLower)
      || p.description.toLowerCase().includes(queryLower)
    );
  }

  const products = results.map((p) => ({
    productId: p.productId,
    name: p.name,
    shortDescription: p.description,
    category: p.category,
    rating: p.rating,
    reviewCount: p.reviewCount,
    imageUrl: p.image_url,
    productUrl: p.url,
  }));

  if (products.length === 0) {
    return {
      content: [{ type: 'text', text: `No products found for "${category}".` }],
      structuredContent: { products: [], total: 0, category: null },
      _meta: { 'openai/widgetDescription': WIDGET_DESCRIPTION }
    };
  }

  return {
    content: [
      { type: 'text', text: `Found ${products.length} product(s) in "${category}".` }
    ],
    structuredContent: { products, total: products.length, category },
    _meta: { 'openai/widgetDescription': WIDGET_DESCRIPTION }
  };
};

What happens at runtime:

  1. A user asks the LLM platform “Show me your coffee products.”
  2. The LLM platform matches the intent to Search Products and extracts category.
  3. The MCP server calls your handler with { category: 'bagged-coffee' }.
  4. Your handler filters the catalog and returns content (text summary for the LLM) + structuredContent (product array for the widget).
  5. The LLM platform shows the text response and passes the structured data to the EDS widget, which renders a product carousel.

What if the handler is missing?

If you defined an action in the UI but have not yet created the handler file, the action is still registered at deploy time. Invocations will use a default stub handler that returns empty content until you add the real code. This means you can define all your actions in the UI first and implement them incrementally.

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