Local handler development and testing development

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.

Use this reference while developing handlers locally. For the handler result contract, see Customize a generated handler.

Requirements

  • Node.js 24 or later.
  • npm.
  • A local clone of the linked handler repository.

For Adobe managed infrastructure, request repository access before following this reference. Local handler execution does not require Adobe credentials, but protected upstream services still require their own authorized access.

Project structure

The current handler boilerplate follows this layout:

your-llm-app/
├── entry.js                   # Webpack entry — do not modify
├── actions/                   # One folder per action
│   └── echo/
│       └── index.js           # Example handler
├── test/
│   ├── actions/
│   │   └── echo.test.js
│   ├── fixtures/
│   │   └── actions.json
│   ├── html-transform.js
│   ├── jest.setup.js
│   └── server.test.js
├── server/
│   └── local.js               # Local dev server (port 9080)
├── actions.json               # Gitignored — optional local metadata
├── app.config.yaml            # Adobe I/O Runtime config
├── webpack.config.js
└── package.json

Key points:

  • entry.js is the webpack entry point. At build time, it discovers every actions/*/index.js file and bundles them into a single dist/index.js. Do not modify the entry point.
  • actions.json is gitignored. The deployment pipeline writes it automatically from the action metadata in LLM Apps.
  • Tests live under test/actions/, not inside actions/. The build discovers handler entry points and bundles their imports; keep tests outside that discovery tree.
  • Action paths use the code identifier or its hyphenated form. For example, search_products resolves to actions/search_products/index.js or, for generated code, actions/search-products/index.js. The display name is not the lookup key.
  • Supporting data such as a generated data.json is bundled when the handler imports it.

Local development

To develop and test handlers locally without Adobe credentials, run:

npm install
npm run dev:local

The development command builds the project with webpack and starts a plain Node.js HTTP server on http://localhost:9080. Stop and rerun npm run dev:local after handler or local metadata changes; the command does not watch files.

To use a different port:

PORT=9081 npm run dev:local

Local metadata behavior

The current UI does not provide an actions.json download, even though the boilerplate README and runtime startup banner mention one. The local server also starts without a populated file: the build creates {"actions":[]} when the file is missing, and the runtime falls back to handler discovery.

Discovery is useful for listing handlers, but it is not a complete argument-testing mode. Without an input schema, the MCP SDK calls the handler with runtime context as its first argument instead of using the normal (args, extra) signature. The SDK does not forward the supplied action arguments.

For MCP calls with arguments, create a local-only actions.json with representative metadata matching the action editor. For the boilerplate echo handler:

{
  "actions": [
    {
      "name": "echo",
      "description": "Echo the supplied message.",
      "inputSchema": {
        "type": "object",
        "properties": {
          "message": { "type": "string" }
        },
        "required": ["message"]
      }
    }
  ]
}

When the actions array is nonempty, the runtime registers only the actions named in the array. Use each action’s code identifier as name. Even an action with no parameters needs an object schema with "properties": {} to retain the normal handler signature.

Local metadata does not update the UI or deployment configuration. The runtime validates supported types, required fields, enums, and array items, but not every JSON Schema constraint; validate lengths, ranges, nested objects, and business rules in the handler. The boilerplate integration tests use test/fixtures/actions.json as representative metadata.

Local app variables

App variables reach the handler through extra.variables, not process.env. Read them with getVariable(extra, name) from @adobe/llm-apps-runtime. To simulate a non-sensitive setting after building:

npm run build
node server/local.js \
  --param 'LLMA_VARIABLE_NAMES=["GREETING_PREFIX"]' \
  --param GREETING_PREFIX=Howdy

Provide both the JSON manifest and the named value. Include the handler in local actions.json with an input schema when testing its normal (args, extra) contract. The command-line flags do not load app settings from the platform. See Configure app variables and secrets for environment-specific deployment settings.

Test with curl

After adding the local echo metadata above, build and start the server, then run:

# List all registered tools
curl -sX POST "http://localhost:9080" \
  -H 'content-type: application/json' \
  -H 'accept: application/json;q=1.0, text/event-stream;q=0.5' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

# Call the boilerplate echo action (returns "Echo: hello")
curl -sX POST "http://localhost:9080" \
  -H 'content-type: application/json' \
  -H 'accept: application/json;q=1.0, text/event-stream;q=0.5' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hello"}}}'

Test with MCP Inspector

npx @modelcontextprotocol/inspector

Set Transport Type to streamable-http and URL to http://localhost:9080.

Testing

Handler unit tests live under test/actions/ and call the exported function directly, without MCP schema validation:

// test/actions/echo.test.js
const handler = require('../../actions/echo/index.js')

test('echoes the message', async () => {
  const result = await handler({ message: 'hello' })
  expect(result.content[0].text).toBe('Echo: hello')
})

test('always returns content parts', async () => {
  const result = await handler({})
  expect(Array.isArray(result.content)).toBe(true)
})

Run tests with:

npm test                                 # configured Jest suite
npm test -- --runInBand test/actions/echo.test.js  # one boilerplate action

Some generated tests use Node’s built-in test runner instead of Jest. Check the test imports and use the matching runner, for example node --test test/actions/search-products.test.js for a file importing node:test. Verify the generated relative imports after placing the file under test/actions/.

Unit tests alone do not verify metadata, MCP validation, widget loading, or host behavior. Test the local endpoint with representative metadata, then test the deployed app in a supported LLM platform.

After local tests pass, push the changes and follow Deploy changes.

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