diff --git a/docs/developer-docs/6.x/background-tasks/about.mdx b/docs/developer-docs/6.x/background-tasks/about.mdx
index 6ef6a852a..1b7a852b0 100644
--- a/docs/developer-docs/6.x/background-tasks/about.mdx
+++ b/docs/developer-docs/6.x/background-tasks/about.mdx
@@ -24,6 +24,12 @@ Tasks allow you to run long operations asynchronously without blocking your appl
To create a background task, you implement the `TaskDefinition.Interface` and register it using `TaskDefinition.createImplementation()`.
+
+
+Background tasks also power Headless CMS bulk actions: every custom [entries bulk action](/{version}/developer-docs/headless-cms/custom-entries-bulk-action) you register is automatically turned into a pair of background tasks (plus a GraphQL mutation) — so you get batching, resume-on-timeout, and monitoring for free, without defining a `TaskDefinition` yourself.
+
+
+
## Example
```typescript
@@ -61,7 +67,7 @@ class PublishEntriesBackgroundTask implements TaskDefinition.Interface {
public constructor(
private getModel: GetModelUseCase.Interface,
- private publishEntry: PublishEntryUseCase.Interface
+ private publishEntry: PublishEntryUseCase.Interface,
) {}
/**
* On each task run, this method is called with the input parameters.
@@ -69,8 +75,10 @@ class PublishEntriesBackgroundTask implements TaskDefinition.Interface {
*/
public async run({
input,
- controller
- }: TaskDefinition.RunParams): Promise> {
+ controller,
+ }: TaskDefinition.RunParams): Promise<
+ TaskDefinition.Result
+ > {
const { model, list } = input;
const modelResult = await this.getModel.execute(model);
@@ -89,82 +97,95 @@ class PublishEntriesBackgroundTask implements TaskDefinition.Interface {
} else if (controller.runtime.isCloseToTimeout()) {
return controller.response.continue({
...input,
- last
+ last,
});
}
last = entryId;
try {
- const publishEntryResult = await this.publishEntry.execute(cmsModel, entryId);
+ const publishEntryResult = await this.publishEntry.execute(
+ cmsModel,
+ entryId,
+ );
if (publishEntryResult.isFail()) {
results.push({
id: entryId,
- error: publishEntryResult.error.message
+ error: publishEntryResult.error.message,
});
continue;
}
results.push({
- id: entryId
+ id: entryId,
});
} catch (ex) {
results.push({
id: entryId,
- error: ex.message
+ error: ex.message,
});
}
}
return controller.response.done({
- list: results
+ list: results,
});
}
/**
* Before the task is triggered, this method is called.
* Users can do some preparation or checks here and break the execution by throwing an error.
*/
- public async onBeforeTrigger(params: TaskDefinition.BeforeTriggerParams): Promise {
+ public async onBeforeTrigger(
+ params: TaskDefinition.BeforeTriggerParams,
+ ): Promise {
//
}
/**
* When the task ends with a done status, this method is called.
*/
- public async onDone(params: TaskDefinition.LifecycleHookParams): Promise {
+ public async onDone(
+ params: TaskDefinition.LifecycleHookParams,
+ ): Promise {
//
}
/**
* When the task ends with an error status, this method is called.
*/
- public async onError(params: TaskDefinition.LifecycleHookParams): Promise {
+ public async onError(
+ params: TaskDefinition.LifecycleHookParams,
+ ): Promise {
//
}
/**
* When the task ends with an abort status, this method is called.
*/
- public async onAbort(params: TaskDefinition.LifecycleHookParams): Promise {
+ public async onAbort(
+ params: TaskDefinition.LifecycleHookParams,
+ ): Promise {
//
}
/**
* When the maximum number of iterations is reached, this method is called.
*/
public async onMaxIterations(
- params: TaskDefinition.LifecycleHookParams
+ params: TaskDefinition.LifecycleHookParams,
): Promise {
//
}
/**
* A method to create input validation schema based on the input interface.
*/
- public createInputValidation({ validator }: TaskDefinition.CreateInputValidationParams) {
+ public createInputValidation({
+ validator,
+ }: TaskDefinition.CreateInputValidationParams) {
return validator.object({
model: validator.string().min(1),
- list: validator.array(validator.string()).min(1).max(10000)
+ list: validator.array(validator.string()).min(1).max(10000),
});
}
}
export default TaskDefinition.createImplementation({
implementation: PublishEntriesBackgroundTask,
- dependencies: [GetModelUseCase, PublishEntryUseCase]
+ dependencies: [GetModelUseCase, PublishEntryUseCase],
});
```
diff --git a/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.ai.txt b/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.ai.txt
new file mode 100644
index 000000000..0ea20866f
--- /dev/null
+++ b/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.ai.txt
@@ -0,0 +1,22 @@
+AI Context: Generate AI Summary Bulk Action (ai-summary-bulk-action.mdx)
+
+## Source of Information
+
+1. wby-next5 `extensions/bulkActions/aiContent/**` — the working "Generate AI summary" demo (API bulk action, dropdown Admin button, websocket handler, extension wiring).
+2. Public APIs (Webiny 6.5.0): `EntriesBulkAction` (`webiny/api/cms/entry`), `CmsGenerateEntryContentUseCase` (`webiny/api/ai-powerups`), `GetSettingsFeature` (`webiny/admin/ai-powerups`), `WebsocketEventHandler` (`webiny/admin/websockets`), `WebsocketsSendToIdentityUseCase` + `IdentityContext` (`webiny/api`, `webiny/api/security`).
+
+## Key Documentation Decisions
+
+1. Explicitly a follow-on to custom-entries-bulk-action.mdx — reader is told to read that first. This page only covers the AI-specific deltas.
+2. Delegates generation to AI Power Ups `CmsGenerateEntryContentUseCase` rather than a raw AI call — so it follows the editor's configured provider + persona/project. Do NOT show model/key handling.
+3. `CmsGenerateEntryContentUseCase.execute(...)` returns `Result` with `value.values` = the generated entry as a structured object (keyed by fieldId). Doc reads `result.value.values.aiSummary` directly, no parse. (This is the reshaped API from webiny-js#5477, targeting release/6.5.0 alongside these docs; serialization now happens only at the transport edge — the task's websocket stream.)
+4. Re-runnable convergence via a per-run token: contrast with the discount page's permanent boolean flag. `runId` fresh per click; `processData` stamps `aiSummarizedRun`; `loadData` filters `aiSummarizedRun_not: runId`. Given its own section.
+5. GraphQL where nests custom fields under `values: {}`; backend `loadData` flattens to `values.`. Shown on both sides (frontend builds nested, backend flattens).
+6. Button is a `DropdownMenu` of AI contexts loaded from `GetSettingsFeature`: Projects, Writer Personas, Reader Personas, plus "Default (no context)". Graceful when AI Power Ups unconfigured (catch → only Default).
+7. Websockets: API emits via `WebsocketsSendToIdentityUseCase` keyed to `IdentityContext.getIdentity()`, best-effort (never fail the task). Admin `WebsocketEventHandler` filters by `action` and toasts via `Notifications`. Registered with `createFeature` + `RegisterFeature`.
+8. Requires AI Power Ups configured — info callout at top.
+
+## Links Used
+
+- Prereq/base: `/{version}/developer-docs/headless-cms/custom-entries-bulk-action` (+ #making-the-task-converge, #registering-the-bulk-action anchors)
+- `/{version}/developer-docs/build-with-ai/ai-assisted-development`
diff --git a/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.mdx b/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.mdx
new file mode 100644
index 000000000..223942326
--- /dev/null
+++ b/docs/developer-docs/6.x/headless-cms/ai-summary-bulk-action.mdx
@@ -0,0 +1,401 @@
+---
+id: wsx4m9re
+title: Generate AI Summary Bulk Action
+description: A bulk action that generates AI content per entry using AI Power Ups, with real-time websocket notifications.
+---
+
+import { Alert } from "@/components/Alert";
+
+
+
+- How to build a bulk action that generates AI content per entry
+- How to delegate generation to AI Power Ups (configured provider + personas/projects)
+- How to let the editor pick a Project or Persona from the button
+- How to make an AI bulk action re-runnable (per-run convergence token)
+- How to notify the user in real time over websockets
+
+
+
+
+
+This page builds on [Custom Entries Bulk Action](/{version}/developer-docs/headless-cms/custom-entries-bulk-action) — read that first. The APIs are available from **Webiny 6.5.0**. Generating content requires [AI Power Ups](/{version}/developer-docs/build-with-ai/ai-assisted-development) to be configured (a provider added in its settings).
+
+
+
+## Overview
+
+AI-per-entry is a natural background-task workload: it's slow, batched, and resumable — exactly what the bulk-action machinery already handles. This example generates a one-sentence marketing summary for each selected product and writes it back to an `aiSummary` field.
+
+Instead of calling an AI provider directly, `processData` delegates to AI Power Ups' `CmsGenerateEntryContentUseCase`. That use case uses the provider the editor configured in AI Power Ups settings and applies an optional **Writer Persona** (tone), **Reader Persona** (audience), or **Project** (bundled instructions + default personas). You don't pick models, decrypt keys, or hardcode a persona.
+
+## The API-Side Bulk Action
+
+```typescript extensions/aiContent/api/GenerateAiSummaryBulkAction.ts
+import {
+ EntriesBulkAction,
+ GetLatestRevisionByEntryIdUseCase,
+ ListLatestEntriesUseCase,
+ UpdateEntryUseCase,
+} from "webiny/api/cms/entry";
+import { CmsGenerateEntryContentUseCase } from "webiny/api/ai-powerups";
+
+// The `data` payload the Admin action sends with each trigger.
+interface GenerateAiSummaryData {
+ projectId?: string;
+ writerPersonaId?: string;
+ readerPersonaId?: string;
+ runId?: string;
+}
+
+class GenerateAiSummaryBulkAction implements EntriesBulkAction.Interface {
+ name = "generateAiSummary";
+ modelIds = ["product"];
+
+ constructor(
+ private readonly listEntries: ListLatestEntriesUseCase.Interface,
+ private readonly getRevision: GetLatestRevisionByEntryIdUseCase.Interface,
+ private readonly updateEntry: UpdateEntryUseCase.Interface,
+ private readonly generateContent: CmsGenerateEntryContentUseCase.Interface,
+ ) {}
+
+ async loadData(
+ model: EntriesBulkAction.Model,
+ params: EntriesBulkAction.LoadDataParams,
+ ): Promise {
+ // The GraphQL `where` arrives with custom-field filters nested under `values`, but the
+ // storage layer wants flat dotted keys (`values.`), so we flatten here.
+ const where: Record = { ...params.where };
+ if (where.values && typeof where.values === "object") {
+ for (const [key, value] of Object.entries(
+ where.values as Record,
+ )) {
+ where[`values.${key}`] = value;
+ }
+ delete where.values;
+ }
+ return (await this.listEntries.execute(model, { ...params, where })).value;
+ }
+
+ async processData(
+ model: EntriesBulkAction.Model,
+ params: EntriesBulkAction.ProcessParams,
+ ): Promise {
+ const entryId = params.id.split("#")[0];
+ const revision = await this.getRevision.execute(model, { id: entryId });
+ if (revision.isFail()) {
+ throw revision.error;
+ }
+ const entry = revision.value;
+
+ // Optional AI Power Ups context, forwarded from the Admin action's `data`.
+ const {
+ projectId,
+ writerPersonaId,
+ readerPersonaId,
+ runId = "",
+ } = params.data ?? {};
+
+ const result = await this.generateContent.execute({
+ modelId: model.modelId,
+ prompt: `Write a concise, single-sentence marketing summary for the product "${entry.values.name}". Fill only the "aiSummary" field.`,
+ writerPersonaId,
+ readerPersonaId,
+ projectId,
+ });
+ if (result.isFail()) {
+ throw result.error;
+ }
+
+ // The use case returns the generated entry as structured `values`; take our field.
+ const generated = result.value.values.aiSummary;
+ const aiSummary = typeof generated === "string" ? generated : "";
+
+ // Stamp the entry with the current run token so it drops out of the next `loadData`.
+ const updated = await this.updateEntry.execute(
+ model,
+ entry.id,
+ { values: { aiSummary, aiSummarizedRun: runId } },
+ { skipValidation: true },
+ );
+ if (updated.isFail()) {
+ throw updated.error;
+ }
+ }
+}
+
+export default EntriesBulkAction.createImplementation({
+ implementation: GenerateAiSummaryBulkAction,
+ dependencies: [
+ ListLatestEntriesUseCase,
+ GetLatestRevisionByEntryIdUseCase,
+ UpdateEntryUseCase,
+ CmsGenerateEntryContentUseCase,
+ ],
+});
+```
+
+### Re-Runnable Convergence: a Per-Run Token
+
+The [discount example](/{version}/developer-docs/headless-cms/custom-entries-bulk-action#making-the-task-converge) converges with a permanent boolean flag (`onSale`), so it runs once per entry. An AI summary is different — you'll want to regenerate it later. A permanent flag would make that impossible.
+
+Instead, each trigger carries a fresh **run token** (`runId`). `processData` stamps every processed entry with it (`aiSummarizedRun`), and `loadData` filters out entries already stamped with the current token. The task still converges (every targeted entry eventually carries this run's token), but the **next** trigger uses a **new** token, so the same entries qualify again and get re-summarized.
+
+## The Admin-Side Button
+
+The button is a dropdown of AI Power Ups contexts, loaded from AI Power Ups settings via `GetSettingsFeature`. Picking one triggers the task with that context; "Default" runs with none.
+
+```tsx extensions/aiContent/admin/GenerateAiSummaryAction.tsx
+import React, { useEffect, useState } from "react";
+import { observer } from "mobx-react-lite";
+import {
+ BulkActionButton,
+ useBulkActionDialog,
+ useFeature,
+} from "webiny/admin";
+import { DropdownMenu, useToast } from "webiny/admin/ui";
+import { useModel } from "webiny/admin/cms";
+import {
+ BulkActionFeature,
+ useContentEntriesPresenter,
+} from "webiny/admin/cms/entry/list";
+import { GetSettingsFeature } from "webiny/admin/ai-powerups";
+
+interface Preset {
+ id: string;
+ name: string;
+}
+
+type ContextChoice =
+ | { projectId: string }
+ | { writerPersonaId: string }
+ | { readerPersonaId: string }
+ | undefined;
+
+export const GenerateAiSummaryAction = observer(() => {
+ const { model } = useModel();
+ const presenter = useContentEntriesPresenter();
+ const { showConfirmationDialog } = useBulkActionDialog();
+ const { showSuccessToast } = useToast();
+ const { useCase: bulkAction } = useFeature(BulkActionFeature);
+ const { useCase: getSettings } = useFeature(GetSettingsFeature);
+
+ const [ctx, setCtx] = useState<{
+ readers: Preset[];
+ writers: Preset[];
+ projects: Preset[];
+ }>({
+ readers: [],
+ writers: [],
+ projects: [],
+ });
+
+ useEffect(() => {
+ let active = true;
+ getSettings
+ .execute()
+ .then((settings) => {
+ if (!active) {
+ return;
+ }
+ setCtx({
+ readers: settings.readerPersonas?.presets ?? [],
+ writers: settings.writerPersonas?.presets ?? [],
+ projects: settings.projects?.presets ?? [],
+ });
+ })
+ .catch(() => {
+ // AI Power Ups may not be configured — just show "Default".
+ });
+ return () => {
+ active = false;
+ };
+ }, [getSettings]);
+
+ const selection = presenter.list.vm.selection;
+ const selectedItems = presenter.list.vm.rows.filter((row) =>
+ selection.selectedIds.has(row.id),
+ );
+
+ const run = (choice: ContextChoice, label: string) =>
+ showConfirmationDialog({
+ title: "Generate AI summary",
+ message: `Generate an AI summary for ${selection.label} (${label})? This runs as a background task, so you can keep working while it processes.`,
+ loadingLabel: "Starting background task…",
+ execute: async () => {
+ // A fresh token per click makes the action re-runnable (see convergence, above).
+ const runId =
+ typeof crypto !== "undefined" && crypto.randomUUID
+ ? crypto.randomUUID()
+ : String(Date.now());
+
+ // Custom-field filters go NESTED under `values` in the GraphQL where input; the
+ // backend flattens this to the storage form.
+ const scope = selection.allSelected
+ ? {}
+ : { id_in: selectedItems.map((item) => item.id) };
+ const where = { ...scope, values: { aiSummarizedRun_not: runId } };
+
+ await bulkAction.execute({
+ model,
+ action: "GenerateAiSummary",
+ where,
+ data: { ...(choice ?? {}), runId },
+ });
+
+ presenter.list.actions.selection.deselectAll();
+
+ showSuccessToast({
+ title: "AI summary task started",
+ description:
+ "Generating summaries in the background. You can keep working.",
+ });
+ },
+ });
+
+ return (
+
+ }
+ >
+ run(undefined, "default")}
+ />
+
+ {ctx.projects.length > 0 ? (
+ <>
+
+
+ {ctx.projects.map((p) => (
+ run({ projectId: p.id }, `project: ${p.name}`)}
+ />
+ ))}
+ >
+ ) : null}
+
+ {ctx.writers.length > 0 ? (
+ <>
+
+
+ {ctx.writers.map((p) => (
+
+ run({ writerPersonaId: p.id }, `writer: ${p.name}`)
+ }
+ />
+ ))}
+ >
+ ) : null}
+
+ );
+});
+```
+
+## Real-Time Notifications Over Websockets
+
+Emit a websocket message from `processData` after each entry, and toast it in the Admin app. On the API side, inject `IdentityContext` and `WebsocketsSendToIdentityUseCase` and send a message (best-effort — never fail the task on a websocket error):
+
+```typescript API side — inside processData, after the entry is updated
+try {
+ const identity = this.identityContext.getIdentity();
+ if (identity) {
+ await this.sendToIdentity.execute(
+ { id: identity.id },
+ {
+ action: "cms.product.aiSummaryGenerated",
+ data: { id: entry.entryId, name: entry.values.name, aiSummary },
+ },
+ );
+ }
+} catch (ex) {
+ this.logger.warn(`[GenerateAiSummary] websocket notification failed`);
+}
+```
+
+On the Admin side, implement a `WebsocketEventHandler` that filters by `action` and shows a toast:
+
+```typescript extensions/aiContent/admin/AiSummaryGeneratedEventHandler.ts
+import { WebsocketEventHandler } from "webiny/admin/websockets";
+import { Notifications } from "webiny/admin";
+
+const AI_SUMMARY_GENERATED_ACTION = "cms.product.aiSummaryGenerated";
+
+interface AiSummaryGeneratedData {
+ id: string;
+ name: string;
+ aiSummary: string;
+}
+
+class AiSummaryGeneratedEventHandlerImpl
+ implements WebsocketEventHandler.Interface
+{
+ constructor(private notifications: Notifications.Interface) {}
+
+ async handle(event: WebsocketEventHandler.Event): Promise {
+ const payload = event.payload as {
+ action?: string;
+ data?: AiSummaryGeneratedData;
+ };
+ if (payload.action !== AI_SUMMARY_GENERATED_ACTION || !payload.data) {
+ return;
+ }
+
+ const { name } = payload.data;
+ this.notifications.success({
+ title: "AI summary generated",
+ description: name ? `Summary ready for "${name}".` : "Summary ready.",
+ });
+ }
+}
+
+export const AiSummaryGeneratedEventHandler =
+ WebsocketEventHandler.createImplementation({
+ implementation: AiSummaryGeneratedEventHandlerImpl,
+ dependencies: [Notifications],
+ });
+```
+
+Register the handler alongside the button using `createFeature` + `RegisterFeature`. The websockets runner resolves every registered `WebsocketEventHandler` and calls `handle` for each incoming message, so each handler filters by its own `action`:
+
+```tsx extensions/aiContent/admin/Extension.tsx
+import React from "react";
+import { createFeature, RegisterFeature } from "webiny/admin";
+import { ContentEntryListConfig } from "webiny/admin/cms/entry/list";
+import { GenerateAiSummaryAction } from "./GenerateAiSummaryAction.js";
+import { AiSummaryGeneratedEventHandler } from "./AiSummaryGeneratedEventHandler.js";
+
+const { Browser } = ContentEntryListConfig;
+
+const AiContentFeature = createFeature({
+ name: "BulkActions/AiContent",
+ register(container) {
+ container.register(AiSummaryGeneratedEventHandler);
+ },
+});
+
+export default () => {
+ return (
+ <>
+
+
+ }
+ modelIds={["product"]}
+ />
+
+ >
+ );
+};
+```
+
+Wiring the two sides into `webiny.config.tsx` follows the same full-stack extension pattern shown in [Custom Entries Bulk Action](/{version}/developer-docs/headless-cms/custom-entries-bulk-action#registering-the-bulk-action).
diff --git a/docs/developer-docs/6.x/headless-cms/custom-entries-bulk-action.ai.txt b/docs/developer-docs/6.x/headless-cms/custom-entries-bulk-action.ai.txt
new file mode 100644
index 000000000..d0a649faa
--- /dev/null
+++ b/docs/developer-docs/6.x/headless-cms/custom-entries-bulk-action.ai.txt
@@ -0,0 +1,31 @@
+AI Context: Custom Entries Bulk Action (custom-entries-bulk-action.mdx)
+
+## Source of Information
+
+1. wby-next5 `extensions/bulkActions/applyDiscount/**` — the working "Apply Discount" demo this doc documents (API bulk action, Admin button, extension wiring, websocket handler).
+2. Public API exposed in Webiny 6.5.0 via the `webiny` package (PR #5411): `EntriesBulkAction` from `webiny/api/cms/entry`.
+
+## Key Documentation Decisions
+
+1. New feature in 6.5.0 — includes a single "available from 6.5.0" info callout. No other version callouts.
+2. Import path: `EntriesBulkAction` from `webiny/api/cms/entry` (NOT `webiny/api/cms/bulk-actions` — that alias was dropped during API review).
+3. Central framing: an `EntriesBulkAction` is auto-turned into a pair of background tasks (list + process) plus a GraphQL mutation. The developer only describes what to do.
+4. The convergence rule is the load-bearing concept — `loadData` is called repeatedly until it returns zero entries, so the filter MUST exclude already-processed entries. Given its own section + it's why `processData` sets a flag.
+5. Storage-level `where`: custom fields are namespaced `values.` at storage; bulk-action list path bypasses the GraphQL where-transform. Documented as a warning callout (repro error: "There is no field with the fieldId onSale").
+6. `skipValidation: true` explained (targeted system update; human edits still validate).
+7. Throw = entry failed, return = entry done.
+8. Admin button uses `webiny/admin` (`BulkActionButton`, `useBulkActionDialog`, `useFeature`) + `webiny/admin/cms/entry/list` (`BulkActionFeature`, `useContentEntriesPresenter`, `ContentEntryListConfig`). `action` is the PascalCased API `name`.
+9. `where` scoping: `{ id_in }` for explicit selection, `undefined` for select-all-across-pages.
+10. Websocket notifications deferred to the sibling AI page to keep this page focused.
+
+## Key API Shapes
+
+- `EntriesBulkAction.Interface` — `name`, `modelIds?`, `batchSize?`, `loadData(model, params)`, `processData(model, params)`.
+- Namespace types: `.Model`, `.LoadDataParams`, `.LoadDataResult`, `.ProcessParams`.
+- `EntriesBulkAction.createImplementation({ implementation, dependencies })`, `export default`.
+- Frontend trigger: `bulkAction.execute({ model, action, where, data })`.
+
+## Links Used
+
+- `/{version}/developer-docs/background-tasks/about` (+ #managing-tasks-via-graph-ql anchor)
+- Sibling: `/{version}/developer-docs/headless-cms/ai-summary-bulk-action`
diff --git a/docs/developer-docs/6.x/headless-cms/custom-entries-bulk-action.mdx b/docs/developer-docs/6.x/headless-cms/custom-entries-bulk-action.mdx
new file mode 100644
index 000000000..3d731f390
--- /dev/null
+++ b/docs/developer-docs/6.x/headless-cms/custom-entries-bulk-action.mdx
@@ -0,0 +1,300 @@
+---
+id: bkq7f3d2
+title: Custom Entries Bulk Action
+description: Learn how to create a custom Headless CMS entries bulk action that Webiny runs as a background task.
+---
+
+import { Alert } from "@/components/Alert";
+
+
+
+- What a Headless CMS entries bulk action is
+- How Webiny turns a bulk action into a background task and a GraphQL mutation
+- How to implement the API-side bulk action (`loadData` / `processData`)
+- How to add the Admin-side button that triggers it
+- How to make the task converge and finish
+- How to notify the user in real time as entries are processed
+
+
+
+
+
+The APIs on this page are available from **Webiny 6.5.0**. The bulk action itself is
+imported from `webiny/api/cms/entry`.
+
+
+
+## Overview
+
+A bulk action lets an editor select multiple entries in the Headless CMS entry list and run a single operation over all of them — for example, apply a discount to every selected product.
+
+The important thing to understand is what Webiny does with a bulk action you register. For every `EntriesBulkAction`, the framework automatically generates:
+
+- a pair of **background tasks** (a "list" task that paginates the matching entries, and a "process" task that runs once per entry, in batches), and
+- a **GraphQL mutation** that the Admin app calls to trigger those tasks.
+
+So you never schedule, chunk, retry, or resume-on-timeout anything yourself — that machinery comes from the [background tasks](/{version}/developer-docs/background-tasks/about) system. You only describe **what** to do: which entries to operate on, and what to do to each one.
+
+A full-stack bulk action has two parts:
+
+1. **API** — the `EntriesBulkAction` implementation (the background-task body).
+2. **Admin** — a button in the entry list that triggers it.
+
+The example below implements an "Apply Discount" bulk action on a `product` model.
+
+## The API-Side Bulk Action
+
+You implement `EntriesBulkAction.Interface` and register it with `EntriesBulkAction.createImplementation()`. The interface has two methods:
+
+- `loadData(model, params)` — collect the entries to operate on. Runs in the "list" task.
+- `processData(model, params)` — operate on a single entry. Runs in the "process" task.
+
+```typescript extensions/applyDiscount/api/ApplyDiscountBulkAction.ts
+import {
+ EntriesBulkAction,
+ GetLatestRevisionByEntryIdUseCase,
+ ListLatestEntriesUseCase,
+ UpdateEntryUseCase,
+} from "webiny/api/cms/entry";
+
+const DEFAULT_DISCOUNT_PERCENT = 10;
+
+// The `data` payload the Admin action sends with each trigger.
+interface ApplyDiscountData {
+ percent?: number;
+}
+
+class ApplyDiscountBulkActionImpl implements EntriesBulkAction.Interface {
+ // The action name. Webiny PascalCases it into the task id + GraphQL enum value, so
+ // "applyDiscount" is triggered from the frontend with `action: "ApplyDiscount"`.
+ public readonly name = "applyDiscount";
+
+ // Only expose this bulk action on the "product" model.
+ public readonly modelIds = ["product"];
+
+ public constructor(
+ private readonly listLatestEntries: ListLatestEntriesUseCase.Interface,
+ private readonly getLatestRevision: GetLatestRevisionByEntryIdUseCase.Interface,
+ private readonly updateEntry: UpdateEntryUseCase.Interface,
+ ) {}
+
+ public async loadData(
+ model: EntriesBulkAction.Model,
+ params: EntriesBulkAction.LoadDataParams,
+ ): Promise {
+ // `params.where` already carries the selection scope sent from the Admin UI.
+ const where: Record = {
+ ...params.where,
+ "values.onSale_not": true,
+ };
+ const result = await this.listLatestEntries.execute(model, {
+ ...params,
+ where,
+ });
+ return result.value;
+ }
+
+ public async processData(
+ model: EntriesBulkAction.Model,
+ params: EntriesBulkAction.ProcessParams,
+ ): Promise {
+ const percent = params.data?.percent ?? DEFAULT_DISCOUNT_PERCENT;
+
+ // `params.id` is a revision id (e.g. "#0001"); strip the version.
+ const entryId = params.id.split("#")[0];
+
+ const revision = await this.getLatestRevision.execute(model, {
+ id: entryId,
+ });
+ if (revision.isFail()) {
+ throw revision.error;
+ }
+
+ const entry = revision.value;
+ const currentPrice = Number(entry.values.price) || 0;
+ const newPrice = Math.round(currentPrice * (1 - percent / 100) * 100) / 100;
+
+ const updated = await this.updateEntry.execute(
+ model,
+ entry.id,
+ { values: { price: newPrice, onSale: true } },
+ { skipValidation: true },
+ );
+ if (updated.isFail()) {
+ throw updated.error;
+ }
+ }
+}
+
+export default EntriesBulkAction.createImplementation({
+ implementation: ApplyDiscountBulkActionImpl,
+ dependencies: [
+ ListLatestEntriesUseCase,
+ GetLatestRevisionByEntryIdUseCase,
+ UpdateEntryUseCase,
+ ],
+});
+```
+
+### Making the Task Converge
+
+This is the single most important detail. The tasks engine calls `loadData` **repeatedly** — after each processing round it re-lists to check whether more work remains, and it stops only when `loadData` returns zero entries.
+
+That means your filter **must exclude entries that have already been processed**, otherwise the task never converges: it would re-discount the same products forever and eventually fail with a `maxIterations` error.
+
+In the example, `processData` sets `onSale: true`, and `loadData` filters those out with `"values.onSale_not": true`. Each round shrinks the working set until nothing is left, and the task finishes.
+
+
+
+The bulk-action list path talks to storage directly and bypasses the GraphQL where-transform. At the storage layer, custom fields are namespaced under `values.` (system fields like `id` stay top-level), so you write the filter as `"values.onSale_not"`. A bare `onSale_not` cannot be resolved and throws _"There is no field with the fieldId onSale"_.
+
+
+
+### `skipValidation`
+
+`processData` updates the entry with `{ skipValidation: true }` because this is a targeted, system-driven field update — it should not fail just because some unrelated field on the entry happens to be empty or invalid. Full validation still runs when a human edits the entry in the Admin app.
+
+### Success and Failure Per Entry
+
+Inside `processData`, **throwing** marks that single entry as _failed_ in the task report; **returning** marks it _done_. The rest of the batch keeps going either way.
+
+## The Admin-Side Button
+
+The button lives in the content entry list and triggers the background task. The browser does **not** loop over entries — it hands the whole selection to the API and the task processes it server-side. The user can navigate away while it runs.
+
+```tsx extensions/applyDiscount/admin/ApplyDiscountAction.tsx
+import React from "react";
+import { observer } from "mobx-react-lite";
+import {
+ BulkActionButton,
+ useBulkActionDialog,
+ useFeature,
+} from "webiny/admin";
+import { useToast } from "webiny/admin/ui";
+import { useModel } from "webiny/admin/cms";
+import {
+ BulkActionFeature,
+ useContentEntriesPresenter,
+} from "webiny/admin/cms/entry/list";
+
+const DISCOUNT_PERCENT = 10;
+
+export const ApplyDiscountAction = observer(() => {
+ const { model } = useModel();
+ const presenter = useContentEntriesPresenter();
+ const { showConfirmationDialog } = useBulkActionDialog();
+ const { showSuccessToast } = useToast();
+ const { useCase: bulkAction } = useFeature(BulkActionFeature);
+
+ const selection = presenter.list.vm.selection;
+ const selectedItems = presenter.list.vm.rows.filter((row) =>
+ selection.selectedIds.has(row.id),
+ );
+
+ const openDialog = () =>
+ showConfirmationDialog({
+ title: "Apply discount",
+ message: `Apply a ${DISCOUNT_PERCENT}% discount to ${selection.label}? This runs as a background task, so you can keep working while it processes.`,
+ loadingLabel: `Processing ${selection.label}`,
+ execute: async () => {
+ // Scope the task to the selected entries. When "select all" (across pages) is
+ // active, omit the filter so the task processes everything matching the view.
+ const where = selection.allSelected
+ ? undefined
+ : { id_in: selectedItems.map((item) => item.id) };
+
+ await bulkAction.execute({
+ model,
+ action: "ApplyDiscount",
+ where,
+ data: { percent: DISCOUNT_PERCENT },
+ });
+
+ presenter.list.actions.selection.deselectAll();
+
+ showSuccessToast({
+ title: "Discount task started",
+ description: `Applying -${DISCOUNT_PERCENT}% in the background. You can keep working.`,
+ });
+ },
+ });
+
+ return (
+
+ );
+});
+```
+
+A few things to note:
+
+- `useFeature(BulkActionFeature)` resolves the use case whose `execute()` fires the generated GraphQL mutation.
+- `action: "ApplyDiscount"` is the PascalCased form of the API `name` (`"applyDiscount"`).
+- `where` is the selection scope. Pass `{ id_in: [...] }` for an explicit selection, or `undefined` when the user chose "select all" across pages, so the task processes everything matching the current view.
+- `data` is the arbitrary payload delivered to `processData` as `params.data` (here, the discount percentage).
+
+## Registering the Bulk Action
+
+On the Admin side, register the button in `ContentEntryListConfig` with a `Browser.BulkAction` whose `name` matches the API `name`:
+
+```tsx extensions/applyDiscount/admin/Extension.tsx
+import React from "react";
+import { ContentEntryListConfig } from "webiny/admin/cms/entry/list";
+import { ApplyDiscountAction } from "./ApplyDiscountAction.js";
+
+const { Browser } = ContentEntryListConfig;
+
+export default () => {
+ return (
+
+ }
+ modelIds={["product"]}
+ />
+
+ );
+};
+```
+
+Then wire both sides into your project with a single extension component and register it in `webiny.config.tsx`:
+
+```tsx extensions/applyDiscount/ApplyDiscountExtension.tsx
+import React from "react";
+import { Admin, Api } from "webiny/extensions";
+
+export const ApplyDiscountExtension = () => {
+ return (
+ <>
+
+
+ >
+ );
+};
+```
+
+```tsx webiny.config.tsx
+import { ApplyDiscountExtension } from "@/extensions/applyDiscount/ApplyDiscountExtension.js";
+
+export const Extensions = () => {
+ return (
+ <>
+ {/* ...other extensions... */}
+
+ >
+ );
+};
+```
+
+## Monitoring the Task
+
+Because a bulk action _is_ a background task, you monitor and troubleshoot it exactly like any other task — list runs, inspect input/output, read logs, or abort it. See [Background Tasks](/{version}/developer-docs/background-tasks/about#managing-tasks-via-graph-ql) for the available GraphQL queries and mutations.
+
+## Real-Time Notifications (Optional)
+
+Since processing happens in the background, it's nice to tell the user as each entry completes. You can emit a websocket message from `processData` and toast it in the Admin app. See [Generate AI Summary Bulk Action](/{version}/developer-docs/headless-cms/ai-summary-bulk-action) for a complete example that adds per-entry websocket notifications on top of the pattern shown here.
diff --git a/docs/developer-docs/6.x/navigation.tsx b/docs/developer-docs/6.x/navigation.tsx
index 1c3b3f37b..b449c9d5e 100644
--- a/docs/developer-docs/6.x/navigation.tsx
+++ b/docs/developer-docs/6.x/navigation.tsx
@@ -2,255 +2,313 @@ import React from "react";
import { Group, NavigationRoot, Page, Separator } from "@webiny/docs-generator";
export const Navigation = ({ children }: { children: React.ReactNode }) => {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/**/}
- {/* */}
- {/* */}
- {/* */}
- {/* */}
- {/* */}
- {/* */}
- {/* */}
- {/* */}
- {/* */}
- {/* */}
- {/* */}
- {/* */}
- {/**/}
- {/**/}
- {/* */}
- {/**/}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/**/}
- {/* */}
- {/* */}
- {/* */}
- {/**/}
- {/**/}
- {/* */}
- {/* */}
- {/**/}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* __REFERENCE_PAGES_START__ */}
- {/* __SDK_PAGES_START__ */}
-
-
-
-
-
-
-
-
-
- {/* __SDK_PAGES_END__ */}
- {/* __REFERENCE_PAGES_END__ */}
-
-
-
-
-
- {children}
-
- );
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/**/}
+ {/* */}
+ {/* */}
+ {/* */}
+ {/* */}
+ {/* */}
+ {/* */}
+ {/* */}
+ {/* */}
+ {/* */}
+ {/* */}
+ {/* */}
+ {/* */}
+ {/**/}
+ {/**/}
+ {/* */}
+ {/**/}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/**/}
+ {/* */}
+ {/* */}
+ {/* */}
+ {/**/}
+ {/**/}
+ {/* */}
+ {/* */}
+ {/**/}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* __REFERENCE_PAGES_START__ */}
+ {/* __SDK_PAGES_START__ */}
+
+
+
+
+
+
+
+
+
+ {/* __SDK_PAGES_END__ */}
+ {/* __REFERENCE_PAGES_END__ */}
+
+
+
+
+
+ {children}
+
+ );
};