@rx-ventures/medusa-flow-builder-plugin
v1.0.0
Published
Shopify-Flow-style visual workflow automation for Medusa v2 — draggable node editor, .flow file import/export, extensible task registry.
Readme
medusa-flow-builder-plugin
Shopify-Flow-style visual workflow automation for Medusa v2.
- Draggable node editor in the admin dashboard — trigger → conditions → wait → actions.
.flowfile import and export that interchanges with Shopify Flow. Task IDs translate both directions.- Extensible task registry — register your own triggers, conditions, and actions from your app with
registerTask. - Durable WAIT — resumptions survive restarts via a persisted pending-resumption table + 1-minute cron sweeper.
- Built-in task catalog — 15 triggers, 2 control nodes (wait, condition), 13 actions covering orders, customers, products, webhooks, Slack, Customer.io, and log output.
Status: alpha. API may change. Not yet published to npm — for now, consume via Medusa's local-package workflow (yalc-based).
Install
Once published to npm
yarn add medusa-flow-builder-pluginUntil then — local install via Medusa's plugin CLI
In the plugin repo:
yarn install
yarn build # runs `medusa plugin:build` → writes .medusa/server + .medusa/admin
yalc publish # publishes to your local yalc store
# (the official `medusa plugin:publish` is broken in @medusajs/[email protected]
# — 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 installRegister in your app's medusa-config.ts:
module.exports = defineConfig({
plugins: [
{ resolve: "medusa-flow-builder-plugin" },
],
// …
})Run the migration so the plugin's tables are created:
yarn medusa db:migrateOpen the admin dashboard → sidebar Extensions → Flow Builder.
New devs: see
HANDOVER.mdfor an end-to-end setup walkthrough that assumes zero Medusa experience.
Built-in tasks
| Kind | Task ID | Description |
|---|---|---|
| Trigger | medusa::order::placed | Order placed |
| Trigger | medusa::order::paid | Order paid |
| Trigger | medusa::order::fulfilled | Order fulfilled |
| Trigger | medusa::order::canceled | Order canceled |
| Trigger | medusa::order::refunded | Order refunded |
| Trigger | medusa::order::updated | Order updated |
| Trigger | medusa::customer::created | Customer created |
| Trigger | medusa::customer::updated | Customer updated |
| Trigger | medusa::product::created | Product created |
| Trigger | medusa::product::updated | Product updated |
| Trigger | medusa::product::deleted | Product deleted |
| Trigger | medusa::cart::updated | Cart updated |
| Trigger | medusa::fulfillment::created | Fulfillment created |
| Trigger | medusa::inventory::level_changed | Inventory level changed |
| Trigger | medusa::flow::custom_webhook | Public inbound webhook (POST /hooks/flow-builder/:token) |
| Control | flow::wait | Pause N seconds/minutes/hours/days before continuing |
| Control | flow::condition | Route through if-then-true / if-then-false output ports |
| Action | medusa::order::add_tags | Append tags to order metadata |
| Action | medusa::order::remove_tags | Remove tags from order metadata |
| Action | medusa::order::add_metafield | Set a namespaced metafield on an order |
| Action | medusa::order::set_metadata | Merge keys into order.metadata |
| Action | medusa::order::cancel | Cancel an order |
| Action | medusa::customer::add_tags | Append customer tags |
| Action | medusa::customer::remove_tags | Remove customer tags |
| Action | medusa::customer::add_metafield | Set a namespaced metafield on a customer |
| Action | medusa::product::add_tags | Append product tags |
| Action | medusa::webhook::post | POST JSON to a URL |
| Action | medusa::slack::post | POST a message to a Slack incoming webhook |
| Action | medusa::customerio::send_email | Send a Customer.io transactional message (requires customerio-node installed) |
| Action | medusa::flow::log_output | Log a templated line to the server logger |
All action config fields support {{ payload.path.to.field }} template interpolation against the triggering event's payload.
Registering your own tasks
Create a file in your app that runs at boot time — a subscriber, a loader, or instrumentation.ts:
// src/subscribers/register-flow-tasks.ts
import { 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 task_id 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.
.flow file import / export
The list page has an Import .flow button that accepts Shopify Flow exports. Shopify task_ids are translated to their Medusa equivalents on import (see src/modules/flow_builder/interop/shopify-map.ts). 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.
Durable WAIT
When a run hits a flow::wait step, it writes a row to flow_builder_pending_resumption (flow_run_id, step_id, resume_at, next_payload, variables) 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: seconds, minutes, hours, days. Resolution is 1 minute; sub-minute waits will fire on the next cron tick.
Local development
Medusa's plugin workflow is yalc-based. Each consuming app gets a copy of the
built plugin under its own .yalc/ directory, with a file: reference in
package.json — no npm registry needed during development.
git clone https://github.com/Rx-Ventures/medusa-flow-builder-plugin
cd medusa-flow-builder-plugin
yarn install
yarn build # medusa plugin:build
yalc publish # publishes to ~/.yalc
# In your Medusa app:
medusa plugin:add medusa-flow-builder-plugin
yarn install
yarn devFor the rapid edit-loop while developing the plugin:
yarn dev # medusa plugin:develop — watch + auto-republishIf you don't want the watcher, do it manually after each change:
yarn build && yalc push # rebuild + push to every yalc consumerHeads up:
medusa plugin:publishfrom@medusajs/[email protected]errors withTypeError: cmd is not a function. As a workaround, runyarn buildfollowed by rawyalc publish— same end result. The bug appears fixed in2.14.x; we'll switch back to the official command once the consumer app upgrades.
Heads up #2:
yalc pushonly copies plugin files into the consumer's.yalc/directory — it does not re-resolve transitivedependencies. If you change the plugin'sdependenciesfield, the consumer needs to re-fetch them:# in the consuming app: yalc update && yarn install --check-files
Publishing to npm (when ready)
yarn build
npm publish --access public # or `npm publish` for a private/scoped packageThe prepublishOnly script re-runs the build for you, so consumers always
get the compiled output. Only the contents of the files whitelist
(.medusa/server, .medusa/admin, package.json, README.md, LICENSE)
end up in the published tarball — source src/ is not shipped.
Testing
yarn test:unitThe suite covers: condition evaluation, template interpolation, WAIT duration parsing, .flow import/export round-trip, the Shopify↔Medusa task translation, and the registerTask extensibility hook.
License
MIT © Rx-Ventures
