Skip to content
Content
Skill

/cms-bulk-actions

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,

BOOST
From plugin
webiny-js
8k76 skills3 MCP
Install
$ npx -y skills add webiny/webiny-js --skill cms-bulk-actions --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/cms-bulk-actions

Context preview

The summary Claude sees to decide when to auto-load this skill.

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,

SKILL.md

cms-bulk-actions.SKILL.md
name: webiny-cms-bulk-actions
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 `<Api.Extension src>`. 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/entry`).

Backend — the bulk action

// extensions/myBulkAction/api/MyBulkAction.ts
import {
  EntriesBulkAction,
  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 ("<entryId>#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 `hcmsBulkList<Name>Entries`, `hcmsBulkProcess<Name>Entries`, and the mutation `bulkAction<SingularApiName>(action: <Name>, ...)`.

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 `<Model>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`:

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:

await this.updateEntry.execute(
  model,
  entry.id,
  { values: { price: newPrice } },
  { skipValidation: true }
)
Read more
Ships withwebiny-js

Open-source content platform. Self-hosted on AWS serverless. Built as a TypeScript framework you extend with code, not a closed product you configure through a UI. Runs on Lambda, DynamoDB, S3, and CloudFront inside your own AWS account. Scales automatically.

Get the whole plugin
Stats
8,048
Stars
682
Forks
Active
Maintenance
TypeScript
Language
2h ago
Last commit
8y ago
Created
9h ago
Added

Repo: webiny/webiny-js

Other skills on webiny-js.