@adobe/llm-apps-runtime
v0.12.1
Published
Server runtime for Adobe LLM Apps — a platform for building AI tools and interactive widgets on Adobe I/O Runtime
Readme
@adobe/llm-apps-runtime
Server runtime for Adobe LLM Apps — a platform for building AI tools and interactive widgets that plug into Claude, Cursor, ChatGPT, and other LLM hosts.
This package powers every Adobe LLM App deployed on Adobe I/O Runtime. It handles the full request lifecycle — action loading, tool registration, widget resources, CORS, and transport — so app developers only need to write their action handlers. Under the hood it speaks the Model Context Protocol via the official MCP TypeScript SDK.
Install
npm install @adobe/llm-apps-runtimeQuick start
Wire the runtime into your Adobe I/O Runtime action:
// entry.js — webpack entry point of your Adobe I/O Runtime action
const { createMain } = require('@adobe/llm-apps-runtime')
const moduleContext = require.context('./actions', true, /index\.js$/)
const htmlContext = require.context('./actions', true, /widget\.html$/)
let actionsConfig = {}
try { actionsConfig = require('./actions.json') } catch (e) { /* optional */ }
module.exports = createMain({ moduleContext, htmlContext, actionsConfig })Write your handlers as plain async functions:
// actions/echo/index.js
module.exports = async ({ message }) => ({
content: [{ type: 'text', text: `Echo: ${message}` }]
})That's it. Deploy with aio app deploy and your handlers are reachable as tools in any LLM host that speaks MCP.
Features
- Zero-config action discovery — drop a folder under
actions/, it becomes a tool - Config-driven metadata — descriptions, schemas, annotations, and widget config come from
actions.json - Interactive widgets — ship
widget.htmlalongside your handler for rich UIs in Claude, Cursor, ChatGPT, etc. - EDS widget support — auto-generate widget markup for Adobe Edge Delivery Services content
- Local development — run your app as a plain HTTP server with no Adobe credentials
- Test-friendly — handlers are plain functions; a filesystem loader lets you write full-stack integration tests without webpack
API
createMain(options)
Creates the Adobe I/O Runtime main(params) function that serves your app.
const { main } = createMain(options)Options
| Option | Type | Default | Description |
|-----------------|-----------------|----------------------|-------------|
| moduleContext | webpack context | — | require.context for actions/**/index.js. Webpack path. |
| htmlContext | webpack context | — | require.context for actions/**/widget.html. Webpack path. |
| actionsConfig | object | {} | Parsed actions.json keyed by action name. Webpack path. |
| actionsDir | string | <cwd>/actions | Absolute path to the actions directory. Fs path. |
| serverName | string | 'adobe-llm-apps' | App name reported to MCP clients. |
| serverVersion | string | '1.0.0' | App version reported to MCP clients. |
When moduleContext is provided, the webpack path is used. Otherwise the filesystem path is used (via actionsDir).
Returns { main } — an async function compatible with Adobe I/O Runtime's action signature.
createLocalServer(main, port, extraParams)
Starts a plain Node.js HTTP server that wraps a main(params) function. Useful for npm run dev:local in consumer apps — no Adobe credentials required.
const { createLocalServer, parseParamArgs } = require('@adobe/llm-apps-runtime/local')
const { main } = require('./dist/index.js') // your webpack bundle
const extraParams = parseParamArgs(process.argv) // --param KEY=VALUE flags
createLocalServer(main, process.env.PORT || 9080, extraParams)Parameters
| Parameter | Type | Default | Description |
|---------------|----------|---------|-------------|
| main | Function | — | The main(params) function returned by createMain. |
| port | number | 9080 | Port to listen on. |
| extraParams | object | {} | Key-value pairs merged into every request's params, simulating Adobe I/O Runtime action params. |
Returns an http.Server instance.
Passing action params locally
In production, Adobe I/O Runtime merges action params (set via aio app deploy --param) into the params object that main() receives alongside the request. Locally there is no runtime, so you pass them on the command line using the same --param KEY=VALUE syntax:
node server/local.js \
--param MY_API_URL=http://localhost:3000 \
--param MY_API_KEY=dev-secretparseParamArgs(process.argv) reads these flags and passes them to createLocalServer as extraParams. Every inbound request then receives them as top-level params fields, so any param-reading code in your action works identically to the deployed runtime.
Action loading primitives
Exported from the package root for advanced use cases (custom loaders, integration tests):
loadActionsFromContexts(server, { moduleContext, htmlContext, actionsConfig })— webpack-based loaderloadActionsFromFs(server, actionsDir, actionsConfig)— filesystem-based loaderloadActionsConfig(actionsDir)— read and keyactions.jsonby name
Most users won't need these — createMain calls them internally based on which options you pass.
Handler contract
A handler is an async function exported from actions/<name>/index.js:
module.exports = async function handler(args) {
return {
// Shown to the LLM and rendered as text by clients
content: [{ type: 'text', text: 'Result' }],
// Optional. Passed to the widget iframe via window.openai.toolOutput.
// Never sent to the LLM.
structuredContent: { key: 'value' }
}
}args is the tool's arguments object, already validated against the inputSchema in actions.json.
See the MCP spec for the full response schema.
actions.json
actions.json is the source of truth for tool metadata. The runtime reads it from one level above actionsDir (i.e. the app root). Each entry drives tool registration:
{
"actions": [
{
"name": "my-action",
"title": "My Action",
"description": "Does something useful",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "The query" }
},
"required": ["query"]
},
"annotations": { "readOnlyHint": true },
"widget_type": "EDS",
"eds_widget": {
"script_url": "https://.../aem-embed.js",
"widget_embed_url": "https://.../embed"
},
"resource_meta": {
"ui": {
"csp": { "connect_domains": ["https://..."] },
"prefersBorder": true
}
},
"tool_meta": {
"openai/widgetAccessible": true
}
}
]
}Widget resolution priority
widget.htmlfile in the action directory- EDS config in
actions.json(auto-generates<aem-embed>markup) - Tool-only (no widget)
Testing your handlers
Handlers are plain async functions — test them directly:
const handler = require('./index.js')
test('echoes the message', async () => {
const out = await handler({ message: 'hello' })
expect(out.content[0].text).toBe('Echo: hello')
})For full-stack integration tests, use the filesystem loader path:
const path = require('path')
const { createMain } = require('@adobe/llm-apps-runtime')
const { main } = createMain({
actionsDir: path.join(__dirname, '..', 'actions')
})
test('tool call returns expected content', async () => {
const res = await main({
__ow_method: 'post',
__ow_body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'echo', arguments: { message: 'hi' } }
}),
__ow_headers: {
'content-type': 'application/json',
accept: 'application/json;q=1.0, text/event-stream;q=0.5'
}
})
const body = JSON.parse(res.body)
expect(body.result.content[0].text).toBe('Echo: hi')
})Requirements
- Node.js
>=24.0.0(matches Adobe I/O Runtimenodejs:24) - Webpack 5 (for bundling when deploying to I/O Runtime)
Related
- Model Context Protocol — The open protocol Adobe LLM Apps speak
- MCP Apps extension — The widget spec this runtime implements
- MCP TypeScript SDK — Underlying protocol implementation
- Adobe I/O Runtime — Serverless platform Adobe LLM Apps deploy to
Releasing
Releases are published to npm automatically via GitHub Actions when a GitHub Release is created.
Steps to release a new version:
- Bump the version in
package.jsonand commit tomain:npm version patch # or minor / major git push - Create a GitHub Release with a tag that matches the version (e.g.
v0.2.0):- Go to Releases → Draft a new release on GitHub
- Set the tag to
v<version>(must matchpackage.json) - Click Publish release
- The
publishworkflow runsnpm testthennpm publishautomatically.
Required secret: Add NPM_TOKEN to the repository's Settings → Secrets and variables → Actions. The token must have the publish permission scoped to the @adobe org (or be a classic Automation token).
License
See LICENSE.
