Shopify-Flow-style visual workflow automation for Medusa v2 — draggable node editor, .flow file import/export, extensible task registry.
Shopify-Flow-style visual workflow automation for Medusa v2.
Status: alpha. API may change. Not yet published to npm — for now, consume via Medusa's local-package workflow (yalc-based).
yarn add medusa-flow-builder-plugin
In the plugin repo:
1yarn install2yarn build # runs `medusa plugin:build` → writes .medusa/server + .medusa/admin3yalc publish # publishes to your local yalc store4 # (the official `medusa plugin:publish` is broken in @medusajs/cli@2.13.35 # — TypeError: cmd is not a function. Raw `yalc publish` does the same job.)
In the consumer Medusa app:
1medusa plugin:add medusa-flow-builder-plugin2# Adds `"medusa-flow-builder-plugin": "file:.yalc/medusa-flow-builder-plugin"` to package.json,3# creates .yalc/medusa-flow-builder-plugin/, and writes yalc.lock.4yarn install
Register in your app's :
1module.exports = defineConfig({2 plugins: [3 { resolve: "medusa-flow-builder-plugin" },4 ],5 // …6})
Run the migration so the plugin's tables are created:
yarn medusa db:migrate
Open the admin dashboard → sidebar Extensions → Flow Builder.
New devs: see for an end-to-end setup walkthrough that assumes zero Medusa experience.
| Kind | Task ID | Description |
|---|---|---|
| Trigger | Order placed | |
| Trigger | Order paid | |
| Trigger | Order fulfilled | |
| Trigger | Order canceled | |
| Trigger | Order refunded | |
| Trigger | Order updated | |
| Trigger | Customer created | |
| Trigger | Customer updated | |
| Trigger | Product created | |
| Trigger | Product updated | |
| Trigger | Product deleted | |
| Trigger | Cart updated | |
| Trigger | Fulfillment created | |
| Trigger | Inventory level changed | |
| Trigger | Public inbound webhook () | |
| Control | Pause N seconds/minutes/hours/days before continuing | |
| Control | Route through / output ports | |
| Action | Append tags to order metadata | |
| Action | Remove tags from order metadata | |
| Action | Set a namespaced metafield on an order | |
| Action | Merge keys into | |
| Action | Cancel an order | |
| Action | Append customer tags | |
| Action | Remove customer tags | |
| Action | Set a namespaced metafield on a customer | |
| Action | Append product tags | |
| Action | POST JSON to a URL | |
| Action | POST a message to a Slack incoming webhook | |
| Action | Send a Customer.io transactional message (requires installed) | |
| Action | Log a templated line to the server logger |
All action config fields support template interpolation against the triggering event's payload.
Create a file in your app that runs at boot time — a subscriber, a loader, or :
1// src/subscribers/register-flow-tasks.ts2import { registerTask } from "medusa-flow-builder-plugin"34registerTask({5 task_id: "acme::subscription::pause",6 task_version: "0.1",7 task_type: "ACTION",8 label: "Pause subscription",9 description: "Pauses the subscription referenced by the payload.",10 category: "Subscription",11 config_fields: [12 { id: "subscription_id", label: "Subscription ID", type: "text", required: true },13 { id: "reason", label: "Reason", type: "text" },14 ],15 input_ports: [{ id: "input", label: "" }],16 output_ports: [{ id: "output", label: "" }],17 async execute(ctx, config) {18 const subscriptionService = ctx.container.resolve("subscription_service")19 await subscriptionService.pause(config.subscription_id, { reason: config.reason })20 return { status: "success" }21 },22})2324// Medusa needs a default export for subscribers; a no-op works:25export default async function noop() {}26export const config = { event: "__boot__:noop" }
The task appears in the admin palette immediately. Re-registering the same overrides the previous definition, including built-ins.
For custom triggers bound to events Medusa doesn't fire natively, register the trigger AND write a subscriber that forwards the event:
1import { registerTask, runFlowsForMedusaEvent } from "medusa-flow-builder-plugin"23registerTask({4 task_id: "acme::billing::invoice_issued",5 task_version: "0.1",6 task_type: "TRIGGER",7 label: "Invoice issued",8 description: "Fires when our billing service issues an invoice.",9 category: "Trigger",10 config_fields: [],11 output_ports: [{ id: "output", label: "" }],12 trigger: { medusa_event: "acme.invoice_issued", payload_shape: { invoice_id: "string", amount: "number" } },13})1415// In a subscriber:16export default async function ({ event: { data }, container }) {17 await runFlowsForMedusaEvent("acme.invoice_issued", data, container)18}19export const config = { event: "acme.invoice_issued" }
The plugin already ships umbrella subscribers for the built-in triggers listed above.
The list page has an Import .flow button that accepts Shopify Flow exports. Shopify s are translated to their Medusa equivalents on import (see ). Unmapped tasks are preserved as-is with an "unsupported" note — flows still open in the editor; unmapped steps are skipped at runtime.
Each row in the list page has an Export .flow button. Exports include a matching SHA-256 prefix of the JSON body. Task IDs reverse-translate to Shopify identifiers where a mapping exists, so the file drops back into Shopify Flow cleanly.
When a run hits a step, it writes a row to (, , , , ) and stops that branch. A cron job runs every minute, sweeps due resumptions, and re-enters the runner at the step downstream of the wait. Runs survive restarts.
Units supported: , , , . Resolution is 1 minute; sub-minute waits will fire on the next cron tick.
Medusa's plugin workflow is yalc-based. Each consuming app gets a copy of the built plugin under its own directory, with a reference in — no npm registry needed during development.
1git clone https://github.com/Rx-Ventures/medusa-flow-builder-plugin2cd medusa-flow-builder-plugin3yarn install4yarn build # medusa plugin:build5yalc publish # publishes to ~/.yalc67# In your Medusa app:8medusa plugin:add medusa-flow-builder-plugin9yarn install10yarn dev
For the rapid edit-loop while developing the plugin:
yarn dev # medusa plugin:develop — watch + auto-republish
If you don't want the watcher, do it manually after each change:
yarn build && yalc push # rebuild + push to every yalc consumer
Heads up: from errors with . As a workaround, run followed by raw — same end result. The bug appears fixed in ; we'll switch back to the official command once the consumer app upgrades.
Heads up #2: only copies plugin files into the consumer's directory — it does not re-resolve transitive . If you change the plugin's field, the consumer needs to re-fetch them:
1# in the consuming app:2yalc update && yarn install --check-files
1yarn build2npm publish --access public # or `npm publish` for a private/scoped package
The script re-runs the build for you, so consumers always get the compiled output. Only the contents of the whitelist (, , , , ) end up in the published tarball — source is not shipped.
yarn test:unit
The suite covers: condition evaluation, template interpolation, WAIT duration parsing, import/export round-trip, the Shopify↔Medusa task translation, and the extensibility hook.
MIT © Rx-Ventures