Create an Action from Scratch create-action-from-scratch

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.
NOTE
This guide assumes basic familiarity with Adobe Edge Delivery Services (EDS). If you are new to EDS, first read the EDS developer tutorial and Exploring blocks to learn the essentials — blocks, the decorate function, and the EDS project structure — before connecting a widget.

Use this guide to add a capability that the platform did not create. You will define the action in LLM Apps, write its handler in the linked repository, add a widget if needed, test it, and deploy it.

Journey: Plan the action → create its metadata → write the handler → connect the widget → test locally → deploy and test the plugin.

For your first app, start with Create your first app automatically.

Before you begin

You need:

  • An existing LLM App.
  • A linked handler repository.
  • The repository cloned locally with its dependencies installed.
  • An EDS project if the action displays a widget.
  • A clear API or data source for production results.

Plan the action

An action should perform one clear user task. Before opening the UI, define:

  • Intent — what the user is trying to accomplish.
  • Description — when the LLM platform should select this action.
  • Inputs — the minimum information required from the user.
  • Result — the text and structured data returned by the handler.
  • Behavior — whether the action reads data, changes data, or calls external systems.
  • Widget — whether the result needs a visual interface.

For example, a Search Products action could use:

Intent: Find products matching a category or search phrase
Inputs:
  category: optional string
  query: optional string
Result:
  content: text summary
  structuredContent: products and total count
Behavior: read-only, idempotent, open-world
Widget: product cards

Keep related but different tasks separate. Product search and product purchase should not be one action because they have different inputs, risks, and confirmation requirements.

Create the action metadata

Open the app and select Actions, then select Create Action.

The editor contains Action and Widget Metadata tabs.

Enter basic information

Create Action — basic information

Enter:

  • Action name — a short task name, such as Search Products.
  • Description — explain when to use the action and what it returns.

A useful description is specific:

Search the product catalog by category or keyword. Returns matching
products with their names, prices, categories, and image URLs.

Avoid vague descriptions such as Gets product information. The LLM platform uses the description to choose between actions.

Select annotations

Annotations describe the action’s behavior:

  • Destructive hint — the action can delete or permanently change data.
  • Idempotent (same args = no extra effect) — repeating the same request has the same effect.
  • Open world hint — the action communicates with external systems.
  • Read only hint — the action does not change data.

Select only annotations that are true. For example, product search is normally read-only, idempotent, and open-world.

Add OpenAI metadata

Enter short messages shown while the action runs and after it completes:

Invoking: Searching products...
Invoked: Products found

For actions with widgets, add Widget description. This is different from the action description:

  • Action description helps the model decide when to invoke the action.
  • Widget description maps to _meta["openai/widgetDescription"] and summarizes what the rendered component shows, reducing repeated narration.

LLM Apps applies this as component metadata. Do not return it from the handler.

Configure visibility

  • Expose to AI model lets the model select the action.
  • Show as widget in app surface displays the configured widget.

Disable widget visibility when the action returns text only.

Add input parameters

Add one parameter for each value the handler accepts. Every parameter needs:

  • Name — the key received by the handler.
  • Type — String, Number, Integer, or Boolean.
  • Description — how the model should extract the value.
  • Required — whether the action can run without it.

For Search Products:

category
  Type: String
  Required: No
  Description: Product category used to narrow the catalog.

query
  Type: String
  Required: No
  Description: Product name or search phrase.

Use stable parameter names. Changing a name also requires changing the handler and its tests.

Configure analytics

Enable Collect user intent when you want analytics to include a summary of the conversation that led to the action.

Create Action — user intent analytics

For complete field definitions, see Action and widget fields.

Configure the widget

Skip this section for a text-only action.

Open Widget Metadata.

Create Action — widget metadata

Configure:

  • Type — select EDS.
  • Widget domain — the EDS origin hosting the widget.
  • Prefers border — requests a bordered container in the host.
  • Script URL — the EDS widget entry point.
  • Widget URL — the published EDS page for this action.

Typical URLs are:

Script URL:
https://main--<repo>--<owner>.aem.live/scripts/aem-embed.js

Widget URL:
https://main--<repo>--<owner>.aem.live/<widget-page>

Grant only required browser permissions and CSP domains.

Create Action — permissions and CSP

If the EDS project or widget page does not exist yet, complete Bring your own EDS project, then return to the action.

Save the action

Select Create new action. The action appears on the Actions page with a Not deployed badge.

At this point, the metadata exists, but the action still needs a handler.

Implement the handler

Clone the linked handler repository and install its dependencies:

npm install

Create:

actions/
└── search-products/
    └── index.js

The folder name must match the action’s code identifier shown in the action editor.

For the complete result contract and handler–widget relationship, see Customize a generated handler.

Handler contract

Export one asynchronous function:

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

The handler receives the parameters defined in the UI.

Return content

content is the text fallback read by the LLM platform:

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

Always return useful content, even when the action has a widget.

Return structuredContent

structuredContent is a plain object consumed by the widget:

structuredContent: {
  products: [
    { id: 'P-100', name: 'Product A', price: '$20' }
  ],
  total: 1
}

The shape must match what the EDS block reads from bridge.toolResult.

Connect an API

Keep protected API access in the server-side handler. Load configuration from the runtime environment and use a fixed HTTPS origin.

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((product) => ({
    id: product.id,
    name: product.name,
    price: product.price
  }));

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

Do not put API credentials in source code, action metadata, widget JavaScript, logs, or user-facing errors.

For production code, validate the complete upstream response before mapping approved fields into structuredContent.

Add handler tests

Create the matching test:

test/
└── actions/
    └── search-products.test.js

Test at least:

  • Valid input.
  • Missing or invalid input.
  • Empty results.
  • API timeout or failure.
  • Malformed API data.
  • The structuredContent shape expected by the widget.

Run:

npm test

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

Test the action locally

Run:

npm run dev:local

Without a local actions.json, the server discovers the handler with minimal metadata and no input-schema validation.

Use MCP Inspector or curl to:

  1. List the registered actions.
  2. Call the new action with representative arguments.
  3. Verify content and structuredContent.
  4. Test invalid and empty requests.

Connect and test the widget

If the action has a widget:

  1. Make the widget read the handler’s structuredContent.
  2. Render external values with safe DOM APIs such as textContent.
  3. Add loading, empty, and error states.
  4. Preview the EDS page locally.
  5. Verify CSP, CORS, and widget URLs.

See Bring your own EDS project.

Deploy and test

  1. Commit and push the handler and widget changes.
  2. Deploy the app to Stage.
  3. Test the ChatGPT plugin.
  4. Verify prompts that should and should not invoke the action.
  5. After Stage succeeds, deploy to Production.

If metadata exists without a matching handler, deployment registers the action with a default stub. Add the handler before making the action available to users.

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