From 78e3489003a4bcdb5f1397bcde6d9b7dfe6acec9 Mon Sep 17 00:00:00 2001 From: adrians5j Date: Fri, 17 Jul 2026 15:45:26 +0200 Subject: [PATCH] docs(mcp-skills): add bulk actions, AI Power Ups content, websocket notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Webiny MCP authoring skills for the 6.5.0 extension APIs: - cms-bulk-actions: custom EntriesBulkAction → auto-generated background task + GraphQL mutation, loadData/processData, convergence, values.-storage vs GraphQL-nested where, skipValidation, the Admin bulk-action button. - ai-powerups-content: generate CMS entry content via CmsGenerateEntryContentUseCase (configured provider + Project / Writer / Reader Persona), output shape, pickers. - websocket-notifications: API → Admin real-time toasts (WebsocketsSendToIdentityUseCase + client WebsocketEventHandler + Notifications). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/ai-powerups-content/SKILL.md | 91 ++++++++ .../user-skills/api/cms-bulk-actions/SKILL.md | 198 ++++++++++++++++++ .../api/websocket-notifications/SKILL.md | 110 ++++++++++ 3 files changed, 399 insertions(+) create mode 100644 skills/user-skills/api/ai-powerups-content/SKILL.md create mode 100644 skills/user-skills/api/cms-bulk-actions/SKILL.md create mode 100644 skills/user-skills/api/websocket-notifications/SKILL.md diff --git a/skills/user-skills/api/ai-powerups-content/SKILL.md b/skills/user-skills/api/ai-powerups-content/SKILL.md new file mode 100644 index 00000000000..941101a362b --- /dev/null +++ b/skills/user-skills/api/ai-powerups-content/SKILL.md @@ -0,0 +1,91 @@ +--- +name: webiny-ai-powerups-content +context: webiny-extensions +description: > + Generating Headless CMS entry content with AI from your own code, by delegating to the + AI Power Ups extension instead of calling an LLM directly. Use this skill when the + developer wants to generate/summarize/rewrite entry content programmatically (e.g. from + a bulk action, a lifecycle hook, or a custom mutation) using the provider and the + Writer/Reader Personas and Projects the user configured in AI Power Ups. Requires the + AI Power Ups extension (with a provider configured) and Webiny 6.5.0 or newer. +--- + +# AI content generation via AI Power Ups + +## TL;DR + +Inject `CmsGenerateEntryContentUseCase` (from `webiny/api/ai-powerups`) and call +`execute(...)`. It uses the provider the user configured in AI Power Ups and applies an +optional **Project**, **Writer Persona**, or **Reader Persona** — so you never pick +models, decrypt API keys, or hardcode prompts. It returns the AI-generated entry values as +a JSON string; parse it and take the field(s) you want. + +Prefer this over a raw `Ai.generateText` call whenever the point is "apply the user's +configured AI setup" — it composes the product with itself and is far less plumbing. + +## Generate content + +```typescript +import { CmsGenerateEntryContentUseCase } from "webiny/api/ai-powerups"; + +class MyThing { + constructor(private generate: CmsGenerateEntryContentUseCase.Interface) {} + + async run(model, entry, ctx) { + const result = await this.generate.execute({ + modelId: model.modelId, + prompt: `Write a one-sentence marketing summary for "${entry.values.name}". Fill only the "aiSummary" field.`, + // Any of these are optional; they map to what the user configured in AI Power Ups: + projectId: ctx?.projectId, // a bundled context (instructions + files + default personas) + writerPersonaId: ctx?.writerPersonaId, // tone + readerPersonaId: ctx?.readerPersonaId // audience + }); + if (result.isFail()) { + throw result.error; // e.g. "No AI provider configured. Add a provider in AI Power Ups settings." + } + return result.value; // { output, telemetry } + } +} +``` + +Register the dependency: `dependencies: [CmsGenerateEntryContentUseCase]`. + +## Output shape — important + +`result.value.output` is `JSON.stringify(resolved)`, where `resolved` is an **entry-shaped +object keyed by the model's field ids** (the use case is built to fill an entry from its +schema). The AI decides which fields it fills, so: + +- Parse it: `const resolved = JSON.parse(result.value.output || "{}")`. +- Take **only** the field(s) you want (e.g. `resolved.aiSummary`) — do NOT blindly write + the whole object back, or the AI could overwrite `name`, `price`, etc. +- Guard for the field being absent (the model may not have filled it): fall back to `""` + and, if you're inside a converging background task, still mark the entry done so it + doesn't loop. + +Then persist with `UpdateEntryUseCase` (values nested; `skipValidation: true` for a +targeted field write). + +## Admin — list configured contexts (for a picker) + +To let the user pick a Project/Persona in the Admin UI, read AI Power Ups settings with +`GetSettingsFeature` (from `webiny/admin/ai-powerups`): + +```tsx +import { useFeature } from "webiny/admin"; +import { GetSettingsFeature } from "webiny/admin/ai-powerups"; + +const { useCase: getSettings } = useFeature(GetSettingsFeature); +const settings = await getSettings.execute(); +// settings.writerPersonas.presets / settings.readerPersonas.presets / settings.projects.presets +// each preset: { id, name, description, ... } +``` + +Forward the chosen id(s) to your backend (e.g. via a bulk action's `data`). + +## Related + +- `webiny-cms-bulk-actions` — the common host for this: a bulk action whose `processData` + calls `CmsGenerateEntryContentUseCase` and writes the result, as a background task. +- `@webiny/ai-powerups`'s `AiImageEnrichmentTask` shows the alternative (raw, hardcoded + `Ai.generateText`) — use that only when you deliberately don't want the configured setup. diff --git a/skills/user-skills/api/cms-bulk-actions/SKILL.md b/skills/user-skills/api/cms-bulk-actions/SKILL.md new file mode 100644 index 00000000000..0c8c808934b --- /dev/null +++ b/skills/user-skills/api/cms-bulk-actions/SKILL.md @@ -0,0 +1,198 @@ +--- +name: webiny-cms-bulk-actions +context: webiny-extensions +description: > + Authoring a custom Headless CMS bulk action (EntriesBulkAction) that Webiny runs as a + background task, plus the Admin-side button that triggers it. Use this skill when the + developer wants to add a bulk action to the content-entry list (e.g. apply a discount, + generate content, bulk-transform entries), understand loadData/processData, make the + task converge, filter by custom fields, or trigger the action from the Admin UI. + Requires Webiny 6.5.0 or newer. +--- + +# Custom Headless CMS bulk actions + +## TL;DR + +A bulk action is a class implementing `EntriesBulkAction.Interface` with two methods — +`loadData` (which entries) and `processData` (what to do to each). Register it with +`export default EntriesBulkAction.createImplementation({...})` via ``. +For every registered bulk action, Webiny **automatically generates** a list background +task, a process background task, and a GraphQL mutation. On the Admin side, add a +`ContentEntryListConfig.Browser.BulkAction` button that calls `BulkActionFeature`'s +`useCase.execute({ model, action, where, data })`. + +Available from **Webiny 6.5.0** (`webiny/api/cms/bulk-actions`). + +## Backend — the bulk action + +```typescript +// extensions/myBulkAction/api/MyBulkAction.ts +import { EntriesBulkAction } from "webiny/api/cms/bulk-actions"; +import { ListLatestEntriesUseCase, UpdateEntryUseCase } from "webiny/api/cms/entry"; + +class MyBulkActionImpl implements EntriesBulkAction.Interface { + // PascalCased into the task ids + GraphQL enum value, so "applyDiscount" → + // tasks hcmsBulk(List|Process)ApplyDiscountEntries and frontend action "ApplyDiscount". + readonly name = "applyDiscount"; + // Optional: restrict which models get the mutation/button. + readonly modelIds = ["product"]; + // Optional: entries processed per batch (defaults to the configured batchSize). + // readonly batchSize = 50; + + constructor( + private listEntries: ListLatestEntriesUseCase.Interface, + private updateEntry: UpdateEntryUseCase.Interface + ) {} + + // Runs in the "list" task, with pagination (params.where/search/after/limit). + async loadData(model, params) { + const result = await this.listEntries.execute(model, params); + return result.value; // { entries, meta } + } + + // Runs in the "process" task, once per entry, in batches. + async processData(model, params) { + // params.id is a revision id ("#0001"); params.data carries whatever the + // Admin action sent. + // ...update / transform the entry here... + } +} + +export default EntriesBulkAction.createImplementation({ + implementation: MyBulkActionImpl, + dependencies: [ListLatestEntriesUseCase, UpdateEntryUseCase] +}); +``` + +`loadData`/`processData` **are** the background-task body. You never write scheduling, +batching, retry, or timeout-resume code — the tasks system provides all of it. Webiny +generates `hcmsBulkListEntries`, `hcmsBulkProcessEntries`, and the mutation +`bulkAction(action: , ...)`. + +## Convergence — the #1 gotcha + +The engine calls `loadData` **repeatedly** until it returns zero entries — after each +processing round it re-lists to check for more work. **If `loadData` keeps returning the +same entries, the task never converges: it re-processes them until it hits `maxIterations` +and fails.** So the filter MUST exclude already-processed entries. + +- **State-transition actions** converge naturally: Publish filters `status_not: "published"` + and `processData` publishes; the next list is smaller. Built-in actions rely on this. +- **Actions with no natural "done" state** need a marker: + - A boolean flag: `loadData` excludes `flag = true`; `processData` sets it. Simple, but + blocks re-running until you reset the flag. + - A **per-run token** (re-runnable): the Admin action generates a fresh `runId` per + click and filters "not stamped with this run"; `processData` stamps the entry with + `runId`. The run converges once everything is stamped, but the next click uses a new + token, so the same entries are eligible again — no manual reset. + +## Where filters — two layers, two formats + +The bulk-action list path talks to storage **directly**, bypassing the GraphQL +where-transform. Mind the difference: + +- **GraphQL where** (what the Admin action sends, typed as `ListWhereInput`): + system fields are top-level (`id_in`, `status_not`, `savedOn_lt`, …); **custom fields + are nested** under `values` — `where: { values: { onSale_not: true } }`. A dotted key + like `"values.onSale_not"` is rejected by the typed input. +- **Storage where** (what `loadData` passes to the list use case): custom fields are + **flat dotted** — `{ "values.onSale_not": true }`; system fields stay top-level. A bare + `onSale_not` throws `There is no field with the fieldId "onSale"`. + +So if the Admin action sends a custom-field filter, flatten it in `loadData`: + +```typescript +async loadData(model, params) { + const where = { ...params.where }; + if (where.values && typeof where.values === "object") { + for (const [k, v] of Object.entries(where.values)) { + where[`values.${k}`] = v; + } + delete where.values; + } + return (await this.listEntries.execute(model, { ...params, where })).value; +} +``` + +Alternatively, add a **constant** custom-field filter entirely in `loadData` (storage +format) and send only system fields from the Admin (that's how the simplest actions work). + +Note: only **searchable** custom fields appear in the GraphQL where input; a plain field +may not be filterable via GraphQL, in which case add the filter backend-side in `loadData`. + +## Updating entries from processData + +Use `UpdateEntryUseCase`; field values are nested under `values`, and pass +`{ skipValidation: true }` for targeted, system-driven field updates so an unrelated +required/invalid field on the entry doesn't fail the operation: + +```typescript +await this.updateEntry.execute(model, entry.id, { values: { price: newPrice } }, { skipValidation: true }); +``` + +To read the current entry inside `processData`, inject `GetLatestRevisionByEntryIdUseCase` +and call `execute(model, { id: params.id.split("#")[0] })`. + +## Admin — the button + +```tsx +// extensions/myBulkAction/admin/Extension.tsx +import { ContentEntryListConfig } from "webiny/admin/cms/entry/list"; +const { Browser } = ContentEntryListConfig; +export default () => ( + + } modelIds={["product"]} /> + +); +``` + +```tsx +// The button. `name` (here on the config) matches the backend action name. +import { observer } from "mobx-react-lite"; +import { BulkActionButton, useBulkActionDialog, useFeature } from "webiny/admin"; +import { useModel } from "webiny/admin/cms"; +import { BulkActionFeature, useContentEntriesPresenter } from "webiny/admin/cms/entry/list"; + +export const MyActionButton = observer(() => { + const { model } = useModel(); + const presenter = useContentEntriesPresenter(); + const { showConfirmationDialog } = useBulkActionDialog(); + const { useCase: bulkAction } = useFeature(BulkActionFeature); + + const selection = presenter.list.vm.selection; + const rows = presenter.list.vm.rows.filter(r => selection.selectedIds.has(r.id)); + + const run = () => showConfirmationDialog({ + title: "Apply discount", + message: `Apply to ${selection.label}? Runs as a background task.`, + execute: async () => { + // System-field scope (id_in) is valid GraphQL; custom-field filters go under `values`. + const where = selection.allSelected ? undefined : { id_in: rows.map(r => r.id) }; + await bulkAction.execute({ model, action: "ApplyDiscount", where, data: { percent: 10 } }); + presenter.list.actions.selection.deselectAll(); + } + }); + + return ; +}); +``` + +The browser never loops over entries — `execute` fires the mutation and the work runs +server-side, in the background. Use `observer` (selection is MobX-observable). The bulk +confirmation dialog only takes strings; for richer input (e.g. a picker) use +`DropdownMenu`/`Select` from `webiny/admin/ui`. + +## Real-time progress (optional) + +`processData` can emit a websocket message per entry via `WebsocketsSendToIdentityUseCase` +(`webiny/api`) + `IdentityContext` (`webiny/api/security`); an admin `WebsocketEventHandler` +(`webiny/admin/websockets`) then toasts via `Notifications` (`webiny/admin`). See the +`webiny-websocket-notifications` skill. + +## Reference + +- Built-in actions live in `@webiny/api-headless-cms-bulk-actions` (Publish, Unpublish, + Delete, Move, Restore) — good templates for `loadData`/`processData`. +- Successful list/process tasks are private and self-clean; failed ones persist (visible + in the Background Tasks screen). diff --git a/skills/user-skills/api/websocket-notifications/SKILL.md b/skills/user-skills/api/websocket-notifications/SKILL.md new file mode 100644 index 00000000000..de09f3409fa --- /dev/null +++ b/skills/user-skills/api/websocket-notifications/SKILL.md @@ -0,0 +1,110 @@ +--- +name: webiny-websocket-notifications +context: webiny-extensions +description: > + Sending real-time notifications from the API to the Admin app over websockets, and + reacting to them on the client. Use this skill when the developer wants to push a + message to the user who triggered some server-side work (e.g. per-entry progress from a + background task/bulk action) and show a toast, update a cache, or refresh UI in response. + Requires Webiny 6.5.0 or newer. +--- + +# Websocket notifications (API → Admin) + +## TL;DR + +On the **API**, inject `WebsocketsSendToIdentityUseCase` (`webiny/api`) + `IdentityContext` +(`webiny/api/security`) and call `sendToIdentity.execute({ id }, { action, data })`. On the +**Admin**, implement a `WebsocketEventHandler` (`webiny/admin/websockets`) that filters on +`action` and reacts (e.g. toast via `Notifications` from `webiny/admin`), registered with +`createFeature` + `RegisterFeature`. + +## API — emit + +```typescript +import { WebsocketsSendToIdentityUseCase } from "webiny/api"; +import { IdentityContext } from "webiny/api/security"; + +class MyTaskOrHook { + constructor( + private identityContext: IdentityContext.Interface, + private sendToIdentity: WebsocketsSendToIdentityUseCase.Interface + ) {} + + async notify(entry) { + // Best-effort: a websocket failure must never fail the real work. + try { + const identity = this.identityContext.getIdentity(); + if (identity) { + await this.sendToIdentity.execute( + { id: identity.id }, + { action: "cms.product.discountApplied", data: { id: entry.entryId, price: entry.values.price } } + ); + } + } catch (ex) { + // log & swallow + } + } +} +// dependencies: [IdentityContext, WebsocketsSendToIdentityUseCase] +``` + +- Send to the user who triggered the work — get them from `IdentityContext`. In a + background task/bulk action, the triggering identity is available. +- Use a namespaced `action` string; put the payload in `data`. +- Sender data type: `{ action?: string; data?: T; error?: {...} }`. + +## Admin — listen + +```typescript +import { WebsocketEventHandler } from "webiny/admin/websockets"; +import { Notifications } from "webiny/admin"; + +const ACTION = "cms.product.discountApplied"; + +class MyHandlerImpl implements WebsocketEventHandler.Interface { + constructor(private notifications: Notifications.Interface) {} + + async handle(event: WebsocketEventHandler.Event): Promise { + const payload = event.payload as { action?: string; data?: { id: string; price: number } }; + if (payload.action !== ACTION || !payload.data) { + return; // every handler sees every message — filter by action + } + this.notifications.success({ title: "Discount applied", description: `New price ${payload.data.price}.` }); + } +} + +export const MyHandler = WebsocketEventHandler.createImplementation({ + implementation: MyHandlerImpl, + dependencies: [Notifications] +}); +``` + +Read the message off `event.payload` — `event.payload.action` and `event.payload.data` +(the exact `{ action, data }` object the API sent). + +## Admin — register + +Register the handler in a feature and render it from your `Admin.Extension`: + +```tsx +import { createFeature, RegisterFeature } from "webiny/admin"; +import { MyHandler } from "./MyHandler.js"; + +const MyFeature = createFeature({ + name: "MyExtension/Notifications", + register(container) { + container.register(MyHandler); + } +}); + +export default () => ; +``` + +The websockets runner resolves every registered `WebsocketEventHandler` and calls `handle` +for each incoming message — hence the `action` filter in each handler. + +## Related + +- `webiny-cms-bulk-actions` — the typical emitter: a bulk action's `processData` sends a + message per processed entry so the Admin can toast progress live.