Mooflair is two projects that work together:
| Project | Path | Stack | Job |
|---|---|---|---|
| mooflair | Script/mooflair | Next.js 16, MongoDB, next-intl, shadcn/ui, @xyflow/react | The app: Studio, Dataset, Runs, Insights, Admin. Stores flows, campaigns and secrets (the config), answers the engine's internal API. |
| engine | Script/engine | Bun, MongoDB, NATS, Docker, Patchright, KasmVNC | Runs flows in real browsers, one container per browser. |
flowchart TB
user([You, in the browser])
subgraph app[mooflair]
ui[Studio · Dataset · Runs · Insights]
actions[Server actions]
flowapi[/api/run/* · /api/hook · /api/form/]
internal[/api/internal/*]
db1[(MongoDB: flows, versions, datasets, users)]
end
subgraph eng[engine]
routes[Routes: /campaign/:id · /flow/:id · /run/:id · /browser/*]
scheduler[Scheduler · every minute]
db2[(MongoDB: apps, browsers, runs, logs)]
bus{{NATS}}
c1[browser container\nagent + Chrome + KasmVNC]
c2[browser container]
end
user --> ui
ui --> actions --> db1
actions -- run, user token --> ui
actions -- start, stop, config (secret) --> routes
routes -- config, what a run needs, events (secret) --> internal
ui -- runs, sockets, live view --> routes
scheduler -- real runs --> routes
routes --> db2
routes <--> bus <--> c1 & c2
c1 & c2 -- tables, caps, cache, notify --> flowapi
Request flows
Running a flow from Studio
- The page calls the server action
aStudioRun, which asks the flow's engine to start a test of the draft (POST /flow/:id/start, server to server with the engine secret). - The engine asks mooflair what the run needs (
POST /api/internal/run): mooflair validates the graph, resolves called flows, gives each node the named browser it acts in, compiles node code with esbuild and puts dataset secrets in. The flow part comes with a hash, so a published flow is sent again only when it changed. - The page gets the run back and opens the run socket with its user token.
- The engine finds the browser for the flow's identity, queues or starts the run, and hands it to the browser's agent over NATS.
- The agent drives Chrome node by node; events flow back through NATS → engine (saved) → the page.
- Nodes that need mooflair's data (tables, caps, cache, notifications) call the run API (
<flow.api.url>/api/run/*, from configuration.json) with the run's token.
Starting from outside
Webhook (/api/hook/<token>), Form (/api/form/<token>), Chat, MCP, polling triggers and /api/v1 start a real run of the published version on the flow's engine (flowStart → POST /flow/:id/start). Campaign windows and the Schedule trigger are the engine's own scheduler: it keeps each app's config (pulled from GET /api/internal/app, patched by mooflair when it changes) and its state (windows, restarts, failures in a row).
Where things live
| Concern | mooflair | engine |
|---|---|---|
| Node definitions (fields, dots, schema, defaults) | src/lib/flow/node/<domain>/<name>/index.ts | — |
| Node code | src/lib/flow/node/<domain>/<name>/run.ts (compiled for the engine) | runs in agent/patchright |
| Templates | src/lib/flow/template/<domain>/<name> | — |
| Expressions | src/lib/flow/expression (preview) | agent/patchright (resolver) |
| What a run needs | src/app/api/internal/run | src/lib/app/run (AppRun.create) |
| Apps (config, state, scheduler) | src/controllers/engine (engineAppConfig, engineAppSync) | App, src/controllers/app |
| Runs, logs | — (reads from the engine) | Run, RunLog |
| Browsers | — | Browser, Docker |
| Editor state | src/stores/studio (zustand) | — |
| Translations | src/lib/i18n/messages/{en,id}.json | — |
Principles
- The engine is generic. It knows no node types; adding a node is a mooflair change only (unless the runtime SDK changes).
- Campaigns own browsers. A campaign (or a flow, in tests) owns browsers by name (
x,shopee…); one main run at a time uses them. - Secrets stay on the servers. They go from mooflair's server to the engine and never reach the page; the engine never stores them.
- Config in mooflair, runtime on the engine. mooflair keeps what you edit; the engine keeps what happens while it runs (runs, logs, app state) and pulls what it needs.
- No compatibility layers. A changed shape changes everywhere, in both apps at once.
- Humans in the loop. Any step can ask for you, and you can always watch or take control of the browser.
FAQ
Why a separate engine?
Browsers need Docker, CPU and memory, and should scale apart from the web app. Several engines (regions, capacity) can serve one mooflair.