Customize a generated handler customize-generated-handler
The platform creates a working handler for every generated action. The handler initially returns sample data for testing the complete experience.
In this guide: Learn how to find the generated handler, replace its sample data with calls to your systems, and keep its response compatible with the widget. Then test and deploy your changes.
Find the generated handler
Open the handler repository connected during infrastructure setup. If you use Adobe managed infrastructure, request code access before continuing.
actions/
└── <action-slug>/
├── index.js
└── data.json
Matching tests live in a separate directory:
test/
└── actions/
└── <action-slug>.test.js
Use the generated pull request to locate the exact files. The folder slug uses hyphens: the platform generates an action with code identifier search_products under actions/search-products/. The runtime recognizes the hyphenated form as well as a folder matching the identifier exactly.
Edit the generated index.js and any associated sample data. Do not change runtime files such as entry.js.
Handler contract
Each handler exports one asynchronous function:
module.exports = async (args, extra) => {
return {
content: [
{ type: 'text', text: 'Response for the LLM platform.' }
],
structuredContent: {
// Data for the widget.
}
};
};
For an action with an input schema, the function receives an args object and an optional runtime context, extra, and returns a result object. Use extra for runtime helpers such as getVariable(extra, 'PRODUCT_API_ORIGIN'); app variables are not automatically exposed through process.env. See Configure app variables and secrets.
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 = '' } = {}, extra) => {
// Validate business rules before using the arguments.
};
With action metadata, the runtime converts the declared input schema into validation for supported rules, including required fields, primitive types, enums, and array items. The runtime does not perform full JSON Schema validation. Check constraints such as string lengths, numeric ranges, and nested object properties in the handler.
Local discovery without schema metadata is not equivalent to deployment: the runtime does not pass action arguments in the normal (args, extra) form. Use direct handler tests or representative local actions.json metadata to test arguments. See Local handler development and testing.
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 the text 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: {
arguments: { query: 'coffee' },
records: [
{
id: 'P-100',
name: 'Frescopa House Blend',
price: '$14.99'
}
]
}
structuredContent is part of the MCP action result delivered to the host. Do not assume that it is hidden from the model or user. Return only authorized data required by the experience, never credentials or private server configuration.
For a text-only action, returning structuredContent is optional.
The handler–widget contract
The handler and widget share one contract: the shape of structuredContent.
contentstructuredContentbridge.toolResultThe widget reads the handler result from the LLM Apps SDK bridge:
export default async function decorate(block, bridge) {
if (!bridge?.isConnected || bridge.hostContext?.preview === true) {
// Render the block's local preview data instead.
return;
}
const result = await bridge.toolResult;
const records = result?.structuredContent?.records ?? [];
// Render records.
}
Current generated handlers use an object containing arguments and records. Existing apps can use a different shape, so check your generated files before editing. Changing a field name or type can break the widget. Update the handler, widget, and tests together.
Replace sample data
Generated handlers load representative records from data.json. Replace the sample-data lookup with a server-side call to your system. The following example uses a public HTTPS catalog API and reads its non-sensitive origin from an app variable. Adapt the response validation to your API.
const { getVariable } = require('@adobe/llm-apps-runtime');
module.exports = async ({ query = '' } = {}, extra) => {
const normalizedQuery = typeof query === 'string' ? query.trim() : '';
if (!normalizedQuery || normalizedQuery.length > 200) {
return {
content: [{ type: 'text', text: 'Enter a valid product search.' }],
structuredContent: { arguments: {}, records: [] },
isError: true
};
}
const knownArguments = { query };
try {
const apiOrigin = getVariable(extra, 'PRODUCT_API_ORIGIN');
if (!apiOrigin) {
throw new Error('Missing configuration');
}
const origin = new URL(apiOrigin);
if (origin.protocol !== 'https:' || origin.username || origin.password) {
throw new Error('Invalid API origin');
}
const url = new URL('/v1/products', origin);
url.searchParams.set('query', normalizedQuery);
const response = await fetch(url, {
signal: AbortSignal.timeout(8000)
});
if (!response.ok) {
throw new Error('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('Unexpected response');
}
const records = payload.products.map(({ id, name, price }) => ({
id,
name,
price
}));
return {
content: [
{ type: 'text', text: `Found ${records.length} matching products.` }
],
structuredContent: { arguments: knownArguments, records }
};
} catch {
return {
content: [{ type: 'text', text: 'Product search is unavailable. Try again later.' }],
structuredContent: { arguments: knownArguments, records: [] },
isError: true
};
}
};
Keep protected network access in the handler. Never put API credentials in widget JavaScript or source control. For requests made on behalf of a signed-in user, see Authenticate end users.
For non-sensitive settings your handler needs, such as a service URL, see Configure app variables and secrets. Configure variables separately for each environment. Variable changes take effect on the next deployment. The current UI supports adding variables only; do not use variables to store API credentials.
Handle expected states
Preserve a predictable output shape for every result.
Results found
{
content: [{ type: 'text', text: 'Found 3 products.' }],
structuredContent: { arguments: { query: 'coffee' }, records: [...] }
}
No results
{
content: [{ type: 'text', text: 'No matching products were found.' }],
structuredContent: { arguments: { query: 'coffee' }, records: [] }
}
A predictable result shape lets the widget render an empty state without guessing whether records exists.
For service failures, return a safe error result with isError: true, as in the example. Check result.isError in the widget so a failed request is not presented as a successful empty search. Do not expose stack traces, credentials, 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.
- Presence of
contentin every result. - A plain object for
structuredContent. - The shape expected by the widget.
Run the repository’s configured test suite:
npm test
If a generated test imports node:test rather than Jest, run it with node --test test/actions/<action-slug>.test.js and verify its import path resolves from test/actions/. Use the test runner appropriate to the file; the boilerplate’s npm test command runs Jest.
For local MCP testing, see Local handler development and testing.
Deploy the change
- Commit and push the handler changes.
- If you change the data shape, update and push the widget.
- Deploy the app to Stage.
- Test in a supported LLM platform. See Test in ChatGPT or Test in Claude.
- After tests pass in Stage, deploy to Production.
Next, see Customize a generated widget.