Customize a Generated Handler customize-generated-handler
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.
contentis always present.structuredContentis 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
- Commit and push the handler changes.
- If the data shape changed, update and push the widget.
- Deploy the app to Stage.
- Test the ChatGPT plugin.
- After Stage succeeds, deploy to Production.
Next, see Customize a generated widget.