Write the Action Handler
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.
actions/<name>/index.jsactions.json (metadata snapshot)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.
.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.
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.
// 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:
- A user asks the LLM platform “Show me your coffee products.”
- The LLM platform matches the intent to Search Products and extracts
category. - The MCP server calls your handler with
{ category: 'bagged-coffee' }. - Your handler filters the catalog and returns
content(text summary for the LLM) +structuredContent(product array for the widget). - 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.