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:
yarn installyarn build # runs `medusa plugin:build` → writes .medusa/server + .medusa/adminyalc publish # publishes to your local yalc store# (the official `medusa plugin:publish` is broken in @medusajs/cli@2.13.3# — TypeError: cmd is not a function. Raw `yalc publish` does the same job.)
In the consumer Medusa app:
medusa plugin:add medusa-flow-builder-plugin# Adds `"medusa-flow-builder-plugin": "file:.yalc/medusa-flow-builder-plugin"` to package.json,# creates .yalc/medusa-flow-builder-plugin/, and writes yalc.lock.yarn install
Register in your app's :
module.exports = defineConfig({plugins: [{ resolve: "medusa-flow-builder-plugin" },],// …})
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 :
// src/subscribers/register-flow-tasks.tsimport { registerTask } from "medusa-flow-builder-plugin"registerTask({task_id: "acme::subscription::pause",task_version: "0.1",task_type: "ACTION",label: "Pause subscription",description: "Pauses the subscription referenced by the payload.",category: "Subscription",config_fields: [{ id: "subscription_id", label: "Subscription ID", type: "text", required: true },{ id: "reason", label: "Reason", type: "text" },],input_ports: [{ id: "input", label: "" }],output_ports: [{ id: "output", label: "" }],async execute(ctx, config) {const subscriptionService = ctx.container.resolve("subscription_service")await subscriptionService.pause(config.subscription_id, { reason: config.reason })return { status: "success" }},})// Medusa needs a default export for subscribers; a no-op works:export default async function noop() {}export 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:
import { registerTask, runFlowsForMedusaEvent } from "medusa-flow-builder-plugin"registerTask({task_id: "acme::billing::invoice_issued",task_version: "0.1",task_type: "TRIGGER",label: "Invoice issued",description: "Fires when our billing service issues an invoice.",category: "Trigger",config_fields: [],output_ports: [{ id: "output", label: "" }],trigger: { medusa_event: "acme.invoice_issued", payload_shape: { invoice_id: "string", amount: "number" } },})// In a subscriber:export default async function ({ event: { data }, container }) {await runFlowsForMedusaEvent("acme.invoice_issued", data, container)}export 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.
git clone https://github.com/Rx-Ventures/medusa-flow-builder-plugincd medusa-flow-builder-pluginyarn installyarn build # medusa plugin:buildyalc publish # publishes to ~/.yalc# In your Medusa app:medusa plugin:add medusa-flow-builder-pluginyarn installyarn 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:
# in the consuming app:yalc update && yarn install --check-files
yarn buildnpm 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